Skip to content

Commit 0527f0c

Browse files
committed
securing email API; making sent emails look polished and prettyyy; polishing feedback sent modal
1 parent 12d7c24 commit 0527f0c

File tree

6 files changed

+186
-61
lines changed

6 files changed

+186
-61
lines changed

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ DB_NAME=tigertype
1515
## Feedback Email
1616
FEEDBACK_EMAIL_FROM=cs-tigertype@princeton.edu
1717
FEEDBACK_EMAIL_TO_TEAM=cs-tigertype@princeton.edu,it.admin@tigerapps.org
18+
FEEDBACK_REPLY_TO=cs-tigertype@princeton.edu,it.admin@tigerapps.org
19+
SITE_URL=https://tigertype.tigerapps.org
1820

1921
# Scraping Configuration
2022
OPENAI_API_KEY=your_api_key_here

client/src/components/FeedbackModal.css

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,16 +100,22 @@
100100
.feedback-success {
101101
display: flex;
102102
flex-direction: column;
103-
gap: 1.25rem;
103+
gap: 1rem;
104104
font-size: 0.95rem;
105105
color: var(--mode-text-color, #f5f5f5);
106+
align-items: center;
107+
text-align: center;
106108
}
107109

108110
.feedback-success p {
109111
margin: 0;
110112
line-height: 1.5;
111113
}
112114

115+
.feedback-success .feedback-primary-button {
116+
min-width: 220px;
117+
}
118+
113119
@media (max-width: 600px) {
114120
.feedback-actions {
115121
flex-direction: column;

client/src/components/FeedbackModal.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ function FeedbackModal({ isOpen, onClose }) {
8585
onClose={closeIfAllowed}
8686
title={submitted ? 'Thanks for your feedback!' : 'Send Feedback'}
8787
showCloseButton
88-
isLarge
88+
isLarge={!submitted}
8989
>
9090
{submitted ? (
9191
<div className="feedback-success">

client/src/components/Navbar.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ function Navbar({ onOpenLeaderboard, onLoginClick }) {
1717
const { authenticated, user, logout, markTutorialComplete } = useAuth();
1818
const { isTutorialRunning, startTutorial, endTutorial } = useTutorial();
1919
const navigate = useNavigate();
20-
const { raceState, typingState, setPlayerReady, resetRace } = useRace();
20+
const { resetRace } = useRace();
2121

2222
// State to track hover state of each link
2323
const [hoveredLink, setHoveredLink] = useState(null);

server/routes/api.js

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,33 @@ router.use('/profile', requireAuth, profileRoutes);
117117

118118
// --- Existing API Routes ---
119119

120+
// simple in-memory rate limiting for feedback (per IP)
121+
const FEEDBACK_LIMIT_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
122+
const FEEDBACK_LIMIT_COUNT = 5; // max 5 submissions per window
123+
const feedbackRate = new Map(); // ip -> { windowStart, count }
124+
125+
function isRateLimited(ip) {
126+
const now = Date.now();
127+
const entry = feedbackRate.get(ip);
128+
if (!entry || (now - entry.windowStart) > FEEDBACK_LIMIT_WINDOW_MS) {
129+
feedbackRate.set(ip, { windowStart: now, count: 1 });
130+
return false;
131+
}
132+
entry.count += 1;
133+
if (entry.count > FEEDBACK_LIMIT_COUNT) return true;
134+
return false;
135+
}
136+
120137
router.post('/feedback', async (req, res) => {
121138
try {
139+
const ip = (req.headers['x-forwarded-for'] || req.ip || req.connection?.remoteAddress || '').toString();
140+
if (isRateLimited(ip)) {
141+
return res.status(429).json({ error: 'Too many feedback submissions. Please try again later.' });
142+
}
122143
const { category = 'feedback', message, contactInfo, pagePath } = req.body || {};
123144

124145
if (!message || typeof message !== 'string' || message.trim().length < 10) {
125-
return res.status(400).json({ error: 'Please include a short description so we can help (10+ characters).' });
146+
return res.status(400).json({ error: 'Please include a description with at least 10 characters so we can help.' });
126147
}
127148

128149
const sanitizedMessage = message.trim().slice(0, 4000);
@@ -142,6 +163,9 @@ router.post('/feedback', async (req, res) => {
142163

143164
const userAgent = req.get('user-agent')?.slice(0, 500) || null;
144165

166+
// acknowledgement policy: only ack authenticated users at their Princeton address
167+
const ackTo = (isAuthenticated(req) && netid) ? `${netid}@princeton.edu` : null;
168+
145169
// fire and forget to avoid hanging if mail provider is slow
146170
sendFeedbackEmails({
147171
category,
@@ -150,7 +174,8 @@ router.post('/feedback', async (req, res) => {
150174
netid,
151175
userAgent,
152176
pagePath: sanitizedPagePath,
153-
createdAt: new Date()
177+
createdAt: new Date(),
178+
ackTo
154179
}).catch(err => console.warn('feedback email send failed', err));
155180

156181
return res.json({ success: true });

server/utils/email.js

Lines changed: 148 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,18 @@ const firstEnv = (...keys) => {
1111
return undefined;
1212
};
1313

14-
const smtpUser = firstEnv('SMTP_SENDER', 'GRAPH_SENDER_USER', 'FEEDBACK_EMAIL_FROM') || 'cs-tigertype@princeton.edu';
14+
// Validate required email configuration
15+
if (!process.env.FEEDBACK_EMAIL_FROM) {
16+
throw new Error('Missing required env var: FEEDBACK_EMAIL_FROM');
17+
}
18+
if (!process.env.FEEDBACK_EMAIL_TO_TEAM) {
19+
throw new Error('Missing required env var: FEEDBACK_EMAIL_TO_TEAM');
20+
}
21+
22+
const smtpUser = firstEnv('SMTP_SENDER', 'GRAPH_SENDER_USER', 'FEEDBACK_EMAIL_FROM');
1523
const tenantId = firstEnv('AZURE_TENANT_ID', 'TENANT_ID');
1624
const clientId = firstEnv('AZURE_CLIENT_ID', 'CLIENT_ID');
25+
const siteUrl = process.env.SITE_URL || 'https://tigertype.tigerapps.org';
1726

1827
// token cache persistence
1928
const CACHE_ENV = 'SMTP_OAUTH_CACHE';
@@ -65,7 +74,7 @@ async function getSmtpAccessToken() {
6574
throw new Error('No delegated SMTP token available. Run: node server/scripts/seed_smtp_oauth_device_login.js');
6675
}
6776

68-
const sendMailGeneric = async ({ to, from, subject, text, replyTo }) => {
77+
const sendMailGeneric = async ({ to, from, subject, text, html, replyTo, cc, attachments }) => {
6978
if (!tenantId || !clientId) throw new Error('Missing AZURE_TENANT_ID/AZURE_CLIENT_ID');
7079
const accessToken = await getSmtpAccessToken();
7180
const transporter = nodemailer.createTransport({
@@ -80,10 +89,17 @@ const sendMailGeneric = async ({ to, from, subject, text, replyTo }) => {
8089
}
8190
});
8291
const mail = { from, to, subject, text };
92+
if (html) mail.html = html;
8393
if (replyTo) mail.replyTo = replyTo;
94+
if (cc) mail.cc = cc;
95+
if (!attachments) attachments = getLogoAttachment();
96+
if (attachments && attachments.length) mail.attachments = attachments;
8497
await transporter.sendMail(mail);
8598
};
8699

100+
// quick email regex (declare early for clarity)
101+
const emailRe = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i;
102+
87103
const sendFeedbackNotification = async ({
88104
category,
89105
message,
@@ -93,9 +109,10 @@ const sendFeedbackNotification = async ({
93109
pagePath,
94110
createdAt,
95111
to,
96-
from
112+
from,
113+
cc
97114
}) => {
98-
const fromAddr = from || process.env.FEEDBACK_EMAIL_FROM || process.env.GRAPH_SENDER_USER || 'cs-tigertype@princeton.edu';
115+
const fromAddr = from || process.env.FEEDBACK_EMAIL_FROM;
99116

100117
const subject = `[TigerType Feedback] ${category.toUpperCase()} from ${netid || 'anonymous user'}`;
101118

@@ -113,24 +130,44 @@ const sendFeedbackNotification = async ({
113130
`User Agent: ${userAgent || 'unknown'}`
114131
];
115132

116-
// set replyTo to contact email if valid so team can reply directly to user
133+
// set replyTo to contact email if present so team can reply directly to user
117134
let replyTo = undefined;
118-
if (contactInfo && emailRe.test(contactInfo)) {
119-
const m = contactInfo.match(emailRe);
120-
if (m) replyTo = m[0];
121-
}
135+
const mReply = contactInfo?.match(emailRe);
136+
if (mReply) replyTo = mReply[0];
122137

123-
await sendMailGeneric({ to, from: fromAddr, subject, text: lines.join('\n'), replyTo });
124-
};
138+
const html = `
139+
<div style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:720px;margin:0 auto;padding:32px 24px;color:#333;background:#ffffff;">
140+
<div style=\"margin:0 0 32px 0;text-align:left;\">
141+
<a href=\"${siteUrl}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"display:inline-block;\">
142+
<img src=\"cid:tt-logo\" alt=\"TigerType\" height=\"56\" style=\"display:block;border:none;\"/>
143+
</a>
144+
</div>
145+
<h3 style=\"margin:0 0 16px 0;color:#F58025;font-weight:800;font-size:24px;line-height:1.2;\">New ${escapeHtml(category)} submitted</h3>
146+
<ul style=\"margin:0 0 24px 0;padding:0 0 0 20px;font-size:15px;line-height:1.8;color:#555;\">
147+
<li><strong>Submitted:</strong> ${escapeHtml(createdAt.toISOString())}</li>
148+
<li><strong>NetID:</strong> ${escapeHtml(netid || 'anonymous')}</li>
149+
<li><strong>Contact:</strong> ${escapeHtml(contactInfo || 'not provided')}</li>
150+
<li><strong>Page:</strong> ${escapeHtml(pagePath || 'unknown')}</li>
151+
<li><strong>User Agent:</strong> ${escapeHtml(userAgent || 'unknown')}</li>
152+
</ul>
153+
<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" style=\"width:100%;margin:0 0 24px 0;border-collapse:separate;border-spacing:0;\">
154+
<tr>
155+
<td style=\"background:#f8f8f8;border-left:4px solid #F58025;border-radius:6px;padding:16px 20px;\">
156+
<div style=\"font-weight:700;margin:0 0 10px 0;font-size:15px;color:#333;\">Message</div>
157+
<div style=\"white-space:pre-wrap;line-height:1.6;color:#555;font-size:15px;\">${escapeHtml(message || '')}</div>
158+
</td>
159+
</tr>
160+
</table>
161+
<hr style=\"border:none;border-top:1px solid #e0e0e0;margin:32px 0 16px 0;\"/>
162+
<p style=\"margin:0;font-size:13px;color:#999;text-align:center;\">Reply to this email to respond directly to the user.</p>
163+
</div>`;
125164

126-
// quick email regex
127-
const emailRe = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i;
165+
await sendMailGeneric({ to, from: fromAddr, subject, text: lines.join('\n'), html, replyTo, cc });
166+
};
128167

129168
const deriveUserEmail = (contactInfo, netid) => {
130-
if (contactInfo && emailRe.test(contactInfo)) {
131-
const m = contactInfo.match(emailRe);
132-
if (m) return m[0];
133-
}
169+
const m = contactInfo?.match(emailRe);
170+
if (m) return m[0];
134171
if (netid && /^[a-z0-9._-]+$/i.test(netid)) {
135172
return `${netid}@princeton.edu`;
136173
}
@@ -147,23 +184,60 @@ const sendFeedbackAcknowledgement = async ({
147184
createdAt
148185
}) => {
149186
if (!to) return;
150-
const subject = 'thanks for your feedback to tigertype';
151-
const lines = [
152-
'hi there — thanks for sending feedback to tigertype',
187+
const subject = 'Thanks for your feedback to TigerType';
188+
const submittedLocal = (createdAt instanceof Date ? createdAt : new Date(createdAt))
189+
.toLocaleString('en-US', { timeZone: 'America/New_York' });
190+
const safeMessage = (message || '').trim();
191+
const summaryRows = [
192+
pagePath ? `<li><strong>Page:</strong> ${escapeHtml(pagePath)}</li>` : '',
193+
contactInfo ? `<li><strong>Contact:</strong> <a href=\"mailto:${escapeAttr(contactInfo)}\">${escapeHtml(contactInfo)}</a></li>` : '',
194+
`<li><strong>Submitted:</strong> ${submittedLocal} ET</li>`
195+
].filter(Boolean).join('');
196+
197+
const html = `
198+
<div style=\"font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:640px;margin:0 auto;padding:32px 24px;color:#333;background:#ffffff;\">
199+
<div style=\"margin:0 0 32px 0;text-align:left;\">
200+
<a href=\"${siteUrl}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"display:inline-block;\">
201+
<img src=\"cid:tt-logo\" alt=\"TigerType\" height=\"56\" style=\"display:block;border:none;\"/>
202+
</a>
203+
</div>
204+
<h2 style=\"margin:0 0 16px 0;color:#F58025;font-weight:800;font-size:28px;line-height:1.2;\">Thanks for your feedback!</h2>
205+
<p style=\"margin:0 0 24px 0;font-size:16px;line-height:1.6;color:#555;\">We really appreciate you taking the time to help improve <strong style=\"color:#F58025;\">TigerType</strong>. Here's a quick summary:</p>
206+
<ul style=\"margin:0 0 24px 0;padding:0 0 0 20px;font-size:15px;line-height:1.8;color:#555;\">${summaryRows}</ul>
207+
${safeMessage ? `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" style=\"width:100%;margin:0 0 24px 0;border-collapse:separate;border-spacing:0;\">
208+
<tr>
209+
<td style=\"background:#f8f8f8;border-left:4px solid #F58025;border-radius:6px;padding:16px 20px;\">
210+
<div style=\"font-weight:700;margin:0 0 10px 0;font-size:15px;color:#333;\">Your message</div>
211+
<div style=\"white-space:pre-wrap;line-height:1.6;color:#555;font-size:15px;\">${escapeHtml(safeMessage)}</div>
212+
</td>
213+
</tr>
214+
</table>
215+
<p style=\"margin:0 0 8px 0;font-size:15px;line-height:1.6;color:#555;\">We'll take a look and follow up if we need any more details.</p>` : ''}
216+
<p style=\"margin:${safeMessage ? '24px' : '0'} 0 0 0;font-size:15px;color:#888;font-style:italic;\">— TigerType Team</p>
217+
<hr style=\"border:none;border-top:1px solid #e0e0e0;margin:32px 0 16px 0;\"/>
218+
<p style=\"margin:0;font-size:13px;color:#999;text-align:center;\">Reply to this email to continue the conversation with the TigerType team.</p>
219+
</div>`;
220+
221+
const text = [
222+
'Thanks for your feedback!',
153223
'',
154-
`we received your ${category || 'feedback'} and will look into it shortly`,
224+
`We received your ${category || 'feedback'} and will look into it shortly.`,
155225
'',
156-
'summary',
157-
`submitted: ${createdAt.toISOString()}`,
158-
pagePath ? `page: ${pagePath}` : null,
159-
contactInfo ? `contact: ${contactInfo}` : null,
226+
'Summary:',
227+
pagePath ? `• Page: ${pagePath}` : null,
228+
contactInfo ? `• Contact: ${contactInfo}` : null,
229+
`• Submitted: ${submittedLocal} ET`,
160230
'',
161-
'message',
162-
message,
231+
safeMessage ? 'Your message:' : null,
232+
safeMessage || null,
163233
'',
164-
'— tigertype team'
165-
].filter(Boolean);
166-
await sendMailGeneric({ from, to, subject, text: lines.join('\n') });
234+
'— TigerType Team'
235+
].filter(Boolean).join('\n');
236+
237+
// Set Reply-To to team addresses (configurable)
238+
const replyTo = process.env.FEEDBACK_REPLY_TO || process.env.FEEDBACK_EMAIL_TO_TEAM;
239+
240+
await sendMailGeneric({ from, to, subject, text, html, replyTo });
167241
};
168242

169243
const sendFeedbackEmails = async ({
@@ -173,15 +247,17 @@ const sendFeedbackEmails = async ({
173247
netid,
174248
userAgent,
175249
pagePath,
176-
createdAt
250+
createdAt,
251+
ackTo // optional: explicit recipient for acknowledgement (null/undefined to suppress)
177252
}) => {
178-
const from = process.env.FEEDBACK_EMAIL_FROM || process.env.GRAPH_SENDER_USER || 'cs-tigertype@princeton.edu';
179-
const teamList = (process.env.FEEDBACK_EMAIL_TO_TEAM || 'cs-tigertype@princeton.edu,it.admin@tigerapps.org')
253+
const from = process.env.FEEDBACK_EMAIL_FROM;
254+
const teamList = process.env.FEEDBACK_EMAIL_TO_TEAM
180255
.split(',')
181256
.map(s => s.trim())
182257
.filter(Boolean);
183258

184-
const toUser = deriveUserEmail(contactInfo, netid);
259+
// acknowledgement recipient policy: only send when explicitly provided (e.g., authenticated user's email)
260+
const toUser = (typeof ackTo !== 'undefined') ? ackTo : deriveUserEmail(contactInfo, netid);
185261

186262
// send acknowledgement first; if it fails, throw
187263
if (toUser) {
@@ -196,32 +272,48 @@ const sendFeedbackEmails = async ({
196272
});
197273
}
198274

199-
// send to each team recipient; if all fail, throw
200-
let sentAny = false;
201-
for (const teamTo of teamList) {
202-
try {
203-
await sendFeedbackNotification({
204-
category,
205-
message,
206-
contactInfo,
207-
netid,
208-
userAgent,
209-
pagePath,
210-
createdAt,
211-
to: teamTo,
212-
from
213-
});
214-
sentAny = true;
215-
} catch (e) {
216-
// continue to next recipient
217-
}
218-
}
219-
if (teamList.length > 0 && !sentAny) {
220-
throw new Error('failed to send to all team recipients');
275+
// send to team recipients; use first as "to" and rest as "cc" so replies-all includes everyone
276+
if (teamList.length > 0) {
277+
const [primaryTeamRecipient, ...ccTeamRecipients] = teamList;
278+
await sendFeedbackNotification({
279+
category,
280+
message,
281+
contactInfo,
282+
netid,
283+
userAgent,
284+
pagePath,
285+
createdAt,
286+
to: primaryTeamRecipient,
287+
from,
288+
cc: ccTeamRecipients.length > 0 ? ccTeamRecipients.join(', ') : undefined
289+
});
221290
}
222291
};
223292

224293
module.exports = {
225294
sendFeedbackNotification,
226295
sendFeedbackEmails
227296
};
297+
298+
// utils for HTML escaping
299+
function escapeHtml(str) {
300+
return String(str)
301+
.replace(/&/g, '&amp;')
302+
.replace(/</g, '&lt;')
303+
.replace(/>/g, '&gt;')
304+
.replace(/\"/g, '&quot;')
305+
.replace(/'/g, '&#39;');
306+
}
307+
function escapeAttr(str) {
308+
return escapeHtml(str).replace(/\n/g, ' ');
309+
}
310+
311+
function getLogoAttachment() {
312+
try {
313+
const logoPath = path.join(__dirname, '../../client/src/assets/logos/navbar-logo.png');
314+
if (fs.existsSync(logoPath)) {
315+
return [{ filename: 'navbar-logo.png', path: logoPath, cid: 'tt-logo' }];
316+
}
317+
} catch (_) {}
318+
return undefined;
319+
}

0 commit comments

Comments
 (0)