66
77const RESEND_ENDPOINT = "https://api.resend.com/emails" ;
88
9+ /**
10+ * Escape text before interpolating it into an HTML email body. Titles, brand
11+ * names and actors can come from external/untrusted sources (e.g. a GitHub
12+ * issue title), so raw interpolation would let them inject markup into the
13+ * rendered email. Plain-text bodies don't need this.
14+ */
15+ function escapeHtml ( value : string | number ) : string {
16+ return String ( value )
17+ . replace ( / & / g, "&" )
18+ . replace ( / < / g, "<" )
19+ . replace ( / > / g, ">" )
20+ . replace ( / " / g, """ )
21+ . replace ( / ' / g, "'" ) ;
22+ }
23+
924export function isEmailConfigured ( ) : boolean {
1025 return Boolean ( process . env . RESEND_API_KEY ) ;
1126}
@@ -84,23 +99,25 @@ export function sendWaitlistVerification(opts: {
8499 `You're almost in. Confirm your email to lock in your spot on the ${ brand } waitlist:\n\n` +
85100 `${ url } \n\n` +
86101 `If you didn't request this, just ignore it — no account is created.` ;
102+ const safeBrand = escapeHtml ( brand ) ;
103+ const safeUrl = escapeHtml ( url ) ;
87104 const html = `<!doctype html>
88105<html><body style="margin:0;background:#0b0b0c;color:#e7e7e7;font-family:ui-monospace,Menlo,Consolas,monospace">
89106 <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px">
90107 <tr><td align="center">
91108 <table role="presentation" width="100%" style="max-width:480px;background:#141416;border:1px solid #26262a;border-radius:12px;padding:28px">
92109 <tr><td>
93- <h1 style="margin:0 0 8px;font-size:22px;color:#9EF01A">${ brand } 🤘</h1>
110+ <h1 style="margin:0 0 8px;font-size:22px;color:#9EF01A">${ safeBrand } 🤘</h1>
94111 <p style="margin:0 0 20px;line-height:1.5;color:#c9c9c9">
95112 You're almost in. Confirm your email to lock in your spot in the pit.
96113 </p>
97114 <p style="margin:0 0 24px">
98- <a href="${ url } " style="display:inline-block;background:#9EF01A;color:#0b0b0c;text-decoration:none;font-weight:700;padding:12px 20px;border-radius:8px">
115+ <a href="${ safeUrl } " style="display:inline-block;background:#9EF01A;color:#0b0b0c;text-decoration:none;font-weight:700;padding:12px 20px;border-radius:8px">
99116 Confirm my spot
100117 </a>
101118 </p>
102119 <p style="margin:0;font-size:12px;color:#8a8a8a;line-height:1.5;word-break:break-all">
103- Or paste this link:<br>${ url }
120+ Or paste this link:<br>${ safeUrl }
104121 </p>
105122 <p style="margin:18px 0 0;font-size:12px;color:#6a6a6a">
106123 Didn't sign up? Ignore this — nothing happens.
@@ -129,17 +146,21 @@ export function sendGithubClosedNotification(opts: {
129146 const text =
130147 `${ opts . actor } ${ verb } ${ opts . kind } #${ opts . number } in ${ opts . repo } \n\n` +
131148 `${ opts . title } \n${ opts . url } \n` ;
149+ const safeRepo = escapeHtml ( opts . repo ) ;
150+ const safeTitle = escapeHtml ( opts . title ) ;
151+ const safeActor = escapeHtml ( opts . actor ) ;
152+ const safeUrl = escapeHtml ( opts . url ) ;
132153 const html = `<!doctype html>
133154<html><body style="margin:0;background:#0b0b0c;color:#e7e7e7;font-family:ui-monospace,Menlo,Consolas,monospace">
134155 <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="padding:28px 16px">
135156 <tr><td align="center">
136157 <table role="presentation" width="100%" style="max-width:480px;background:#141416;border:1px solid #26262a;border-radius:12px;padding:24px">
137158 <tr><td>
138- <p style="margin:0 0 6px;font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:#8a8a8a">${ opts . repo } </p>
159+ <p style="margin:0 0 6px;font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:#8a8a8a">${ safeRepo } </p>
139160 <h1 style="margin:0 0 12px;font-size:18px;color:#9EF01A">${ opts . kind } #${ opts . number } ${ verb } 🤘</h1>
140- <p style="margin:0 0 16px;line-height:1.5;color:#d6d6d6">${ opts . title } </p>
141- <p style="margin:0 0 18px;font-size:13px;color:#9a9a9a">by ${ opts . actor } </p>
142- <a href="${ opts . url } " style="display:inline-block;background:#9EF01A;color:#0b0b0c;text-decoration:none;font-weight:700;padding:10px 18px;border-radius:8px">View on GitHub</a>
161+ <p style="margin:0 0 16px;line-height:1.5;color:#d6d6d6">${ safeTitle } </p>
162+ <p style="margin:0 0 18px;font-size:13px;color:#9a9a9a">by ${ safeActor } </p>
163+ <a href="${ safeUrl } " style="display:inline-block;background:#9EF01A;color:#0b0b0c;text-decoration:none;font-weight:700;padding:10px 18px;border-radius:8px">View on GitHub</a>
143164 </td></tr>
144165 </table>
145166 </td></tr>
@@ -161,23 +182,25 @@ export function sendPasswordReset(opts: {
161182 `Someone asked to reset the password for your ${ brand } account.\n\n` +
162183 `Set a new one here (link expires in 1 hour):\n${ url } \n\n` +
163184 `If this wasn't you, ignore this email — your password stays unchanged.` ;
185+ const safeBrand = escapeHtml ( brand ) ;
186+ const safeUrl = escapeHtml ( url ) ;
164187 const html = `<!doctype html>
165188<html><body style="margin:0;background:#0b0b0c;color:#e7e7e7;font-family:ui-monospace,Menlo,Consolas,monospace">
166189 <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px">
167190 <tr><td align="center">
168191 <table role="presentation" width="100%" style="max-width:480px;background:#141416;border:1px solid #26262a;border-radius:12px;padding:28px">
169192 <tr><td>
170- <h1 style="margin:0 0 8px;font-size:22px;color:#9EF01A">${ brand } 🤘</h1>
193+ <h1 style="margin:0 0 8px;font-size:22px;color:#9EF01A">${ safeBrand } 🤘</h1>
171194 <p style="margin:0 0 20px;line-height:1.5;color:#c9c9c9">
172195 Reset your password. This link expires in 1 hour.
173196 </p>
174197 <p style="margin:0 0 24px">
175- <a href="${ url } " style="display:inline-block;background:#9EF01A;color:#0b0b0c;text-decoration:none;font-weight:700;padding:12px 20px;border-radius:8px">
198+ <a href="${ safeUrl } " style="display:inline-block;background:#9EF01A;color:#0b0b0c;text-decoration:none;font-weight:700;padding:12px 20px;border-radius:8px">
176199 Set a new password
177200 </a>
178201 </p>
179202 <p style="margin:0;font-size:12px;color:#8a8a8a;line-height:1.5;word-break:break-all">
180- Or paste this link:<br>${ url }
203+ Or paste this link:<br>${ safeUrl }
181204 </p>
182205 <p style="margin:18px 0 0;font-size:12px;color:#6a6a6a">
183206 Didn't ask for this? Ignore it — nothing changes.
0 commit comments