@@ -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' ) ;
1523const tenantId = firstEnv ( 'AZURE_TENANT_ID' , 'TENANT_ID' ) ;
1624const clientId = firstEnv ( 'AZURE_CLIENT_ID' , 'CLIENT_ID' ) ;
25+ const siteUrl = process . env . SITE_URL || 'https://tigertype.tigerapps.org' ;
1726
1827// token cache persistence
1928const 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 - Z 0 - 9 . _ % + - ] + @ [ A - Z 0 - 9 . - ] + \. [ A - Z ] { 2 , } / i;
102+
87103const 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 - Z 0 - 9 . _ % + - ] + @ [ A - Z 0 - 9 . - ] + \. [ A - Z ] { 2 , } / i ;
165+ await sendMailGeneric ( { to , from : fromAddr , subject , text : lines . join ( '\n' ) , html , replyTo , cc } ) ;
166+ } ;
128167
129168const 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 - z 0 - 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
169243const 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
224293module . exports = {
225294 sendFeedbackNotification,
226295 sendFeedbackEmails
227296} ;
297+
298+ // utils for HTML escaping
299+ function escapeHtml ( str ) {
300+ return String ( str )
301+ . replace ( / & / g, '&' )
302+ . replace ( / < / g, '<' )
303+ . replace ( / > / g, '>' )
304+ . replace ( / \" / g, '"' )
305+ . replace ( / ' / g, ''' ) ;
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