server.js (22483B)
1 const express = require('express'); 2 const { Resend } = require('resend'); 3 const { Webhook } = require('svix'); 4 const { simpleParser } = require('mailparser'); 5 const admin = require('firebase-admin'); 6 7 const app = express(); 8 const port = process.env.PORT || 3000; 9 10 const resend = new Resend(process.env.RESEND_API_KEY); 11 const webhookSecret = process.env.RESEND_WEBHOOK_SECRET; 12 13 const escapeHtml = (value) => 14 String(value ?? '') 15 .replace(/&/g, '&') 16 .replace(/</g, '<') 17 .replace(/>/g, '>') 18 .replace(/"/g, '"') 19 .replace(/'/g, '''); 20 21 // Pull a display name out of `"Jane Doe" <jane@example.com>`; fall back to the address. 22 // The result is stripped of characters that could break a From header. 23 const displayNameOf = (from) => { 24 const match = String(from || '').match(/^\s*(?:"([^"]*)"|([^<]+?))\s*<([^>]+)>\s*$/); 25 const name = match ? (match[1] || match[2] || match[3]) : String(from || 'Unknown sender'); 26 return name.replace(/[<>"\\\r\n]/g, '').trim() || 'Unknown sender'; 27 }; 28 29 // Bare address from `"Jane" <jane@example.com>` or `jane@example.com`. 30 const addressOf = (value) => { 31 const match = String(value || '').match(/<([^>]+)>/); 32 return (match ? match[1] : String(value || '')).trim(); 33 }; 34 35 const DISPLAY_TZ = process.env.DISPLAY_TZ || 'Asia/Kolkata'; 36 const formatReceived = (date) => 37 new Intl.DateTimeFormat('en-GB', { 38 timeZone: DISPLAY_TZ, 39 weekday: 'short', day: '2-digit', month: 'short', year: 'numeric', 40 hour: '2-digit', minute: '2-digit', hour12: true, timeZoneName: 'short', 41 }).format(date).replace('GMT+5:30', 'IST'); 42 43 // Info banner shown above the original message: who sent it, where it arrived, 44 // when, a trace link into Resend, and a Reply button. Inline styles only, so it 45 // survives email clients. 46 const buildBanner = ({ from, to, receivedAt, emailId, subject, replyTarget, attachmentCount }) => { 47 const replySubject = /^re:/i.test(subject) ? subject : `Re: ${subject}`; 48 const replyHref = `mailto:${encodeURI(addressOf(replyTarget))}?subject=${encodeURIComponent(replySubject)}`; 49 const traceHref = `https://resend.com/emails/${encodeURIComponent(emailId)}`; 50 51 const label = 'width: 84px; padding: 6px 0; vertical-align: top; font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: #5f6368;'; 52 const value = 'padding: 6px 0; vertical-align: top; font-size: 14px; line-height: 1.4; color: #1f1f1f; word-break: break-word;'; 53 54 const row = (name, content) => 55 `<tr><td style="${label}">${name}</td><td style="${value}">${content}</td></tr>`; 56 57 const attachmentRow = attachmentCount > 0 58 ? row('Files', `${attachmentCount} attachment${attachmentCount === 1 ? '' : 's'} (included below)`) 59 : ''; 60 61 return `<div style="max-width: 720px; margin: 0 0 20px 0; font-family: Roboto, Arial, Helvetica, sans-serif;">` 62 + `<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="border: 1px solid #dadce0; border-radius: 12px; border-collapse: separate; background-color: #f6f8fc;">` 63 + `<tr><td style="padding: 16px 20px 4px 20px;">` 64 + `<div style="font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: #0b57d0; margin-bottom: 8px;">Incoming mail</div>` 65 + `<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">` 66 + row('From', `<b>${escapeHtml(displayNameOf(from))}</b> <${escapeHtml(addressOf(from))}>`) 67 + row('To', escapeHtml(to)) 68 + row('Received', escapeHtml(formatReceived(receivedAt))) 69 + row('Trace ID', `<a href="${escapeHtml(traceHref)}" target="_blank" style="font-family: 'Roboto Mono', Consolas, monospace; font-size: 12px; color: #0b57d0; text-decoration: none;">${escapeHtml(emailId)}</a>`) 70 + attachmentRow 71 + `</table></td></tr>` 72 + `<tr><td style="padding: 8px 20px 18px 20px;">` 73 + `<a href="${escapeHtml(replyHref)}" style="display: inline-block; padding: 9px 22px; margin-right: 8px; background-color: #0b57d0; color: #ffffff; font-size: 14px; font-weight: 500; text-decoration: none; border-radius: 100px;">Reply</a>` 74 + `<a href="${escapeHtml(traceHref)}" target="_blank" style="display: inline-block; padding: 8px 20px; border: 1px solid #0b57d0; color: #0b57d0; font-size: 14px; font-weight: 500; text-decoration: none; border-radius: 100px;">Trace in Resend</a>` 75 + `</td></tr></table></div>`; 76 }; 77 78 // Put the banner just inside the original message's <body>, so the result is 79 // still one valid HTML document. Messages without a <body> get it prepended. 80 const injectBanner = (html, banner) => { 81 const bodyOpen = /<body[^>]*>/i; 82 return bodyOpen.test(html) ? html.replace(bodyOpen, (tag) => tag + banner) : banner + html; 83 }; 84 85 // ---- Firestore archive (optional) ------------------------------------------- 86 // Set FIREBASE_SERVICE_ACCOUNT to the service-account JSON (raw or base64). 87 // Without it the server still forwards mail; it just skips the archive. 88 let db = null; 89 try { 90 const rawAccount = process.env.FIREBASE_SERVICE_ACCOUNT; 91 if (rawAccount) { 92 const json = rawAccount.trim().startsWith('{') 93 ? rawAccount 94 : Buffer.from(rawAccount, 'base64').toString('utf8'); 95 admin.initializeApp({ credential: admin.credential.cert(JSON.parse(json)) }); 96 db = admin.firestore(); 97 } else { 98 console.warn('FIREBASE_SERVICE_ACCOUNT not set - mail will not be archived'); 99 } 100 } catch (err) { 101 console.error('Firebase init failed - mail will not be archived:', err.message); 102 } 103 104 // Firestore documents are capped at ~1 MiB, so keep each body field well under it. 105 const MAX_BODY_CHARS = 250000; 106 const capBody = (value) => (value ? value.slice(0, MAX_BODY_CHARS) : null); 107 108 // Save one received mail, keyed by Resend's email ID. create() fails if the 109 // document exists, so a webhook retry never duplicates it or resets `read`. 110 const archiveEmail = async ({ emailId, from, to, subject, receivedAt, parsed, fetchError }) => { 111 if (!db) return; 112 try { 113 await db.collection('mails').doc(emailId).create({ 114 from, 115 to, 116 subject, 117 html: capBody(parsed?.html), 118 text: capBody(parsed?.text), 119 truncated: (parsed?.html?.length || 0) > MAX_BODY_CHARS || (parsed?.text?.length || 0) > MAX_BODY_CHARS, 120 receivedAt: admin.firestore.Timestamp.fromDate(receivedAt), 121 resendEmailId: emailId, 122 read: false, 123 messageId: parsed?.messageId || null, 124 inReplyTo: parsed?.inReplyTo || null, 125 references: parsed?.references ? [].concat(parsed.references) : [], 126 attachments: (parsed?.attachments || []).map((a) => ({ 127 filename: a.filename || 'attachment', 128 contentType: a.contentType || null, 129 size: a.size || 0, 130 })), 131 fetchError: fetchError || null, 132 }); 133 } catch (err) { 134 if (err.code === 6 || /already exists/i.test(err.message)) return; // retry of a mail we already saved 135 throw err; 136 } 137 }; 138 139 // Resend's email.received webhook only carries metadata (no body, headers or 140 // attachments). Fetch the message itself via the Received Emails API. 141 const fetchFullEmail = async (emailId) => { 142 const { data: received, error } = await resend.emails.receiving.get(emailId); 143 if (error) throw new Error(`Could not retrieve received email: ${error.message}`); 144 145 const downloadUrl = received?.raw?.download_url; 146 if (!downloadUrl) throw new Error('Received email has no raw download URL'); 147 148 const rawResponse = await fetch(downloadUrl); 149 if (!rawResponse.ok) throw new Error(`Raw email download failed (${rawResponse.status})`); 150 151 const raw = Buffer.from(await rawResponse.arrayBuffer()); 152 return simpleParser(raw, { skipImageLinks: true }); 153 }; 154 155 app.post('/api/incoming', express.text({ type: 'application/json' }), async (req, res) => { 156 try { 157 const rawBody = req.body; 158 const headers = { 159 'svix-id': req.headers['svix-id'], 160 'svix-timestamp': req.headers['svix-timestamp'], 161 'svix-signature': req.headers['svix-signature'], 162 }; 163 164 const wh = new Webhook(webhookSecret); 165 let event; 166 167 try { 168 event = wh.verify(rawBody, headers); 169 } catch (err) { 170 console.error(err.message); 171 return res.status(400).json({ error: 'Invalid signature' }); 172 } 173 174 if (event.type !== 'email.received') { 175 return res.status(200).json({ message: 'Not an email event' }); 176 } 177 178 const emailData = event.data; 179 const originalSender = emailData.from; 180 const originalRecipient = emailData.to[0].toLowerCase(); 181 const subject = emailData.subject || 'No Subject'; 182 183 const allowedString = process.env.ALLOWED_ALIASES || ''; 184 const allowedAliases = allowedString.split(',').map(alias => alias.trim().toLowerCase()); 185 186 if (!allowedAliases.includes(originalRecipient)) { 187 return res.status(200).json({ success: true, message: 'Alias ignored' }); 188 } 189 190 const personalGmail = process.env.PERSONAL_GMAIL; 191 const forwardingAddress = process.env.FORWARDING_BOT_ADDRESS; 192 193 // Premium Adaptive Google-style HTML Template. 194 // Only used as a fallback now, if the full message cannot be fetched. 195 const buildNotificationHtml = () => ` 196 <!DOCTYPE html> 197 <html lang="en" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"> 198 <head> 199 <meta charset="UTF-8"> 200 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 201 <meta name="color-scheme" content="light dark"> 202 <meta name="supported-color-schemes" content="light dark"> 203 <title>New Email Notification</title> 204 205 <style> 206 @import url('https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500&family=Roboto+Mono:wght@400;500&family=Roboto:wght@400;500&display=swap'); 207 208 body { 209 margin: 0; 210 padding: 0; 211 -webkit-font-smoothing: antialiased; 212 -moz-osx-font-smoothing: grayscale; 213 } 214 215 .btn-pill:hover { 216 opacity: 0.9; 217 transform: translateY(-1px); 218 transition: all 0.2s ease; 219 } 220 221 @media (prefers-color-scheme: dark) { 222 .bg-outer { background-color: #131314 !important; } 223 .bg-card { background-color: #1E1F20 !important; } 224 .bg-tint { background-color: #282A2C !important; } 225 .text-primary { color: #E3E3E3 !important; } 226 .text-secondary { color: #C4C7C5 !important; } 227 .text-accent { color: #A8C7FA !important; } 228 .border-card { border-color: #444746 !important; } 229 .divider { background-color: #444746 !important; } 230 .btn-pill { 231 background-color: #A8C7FA !important; 232 color: #000000 !important; 233 } 234 .icon-fill { fill: #8E918F !important; } 235 } 236 237 /* Gmail Dark Mode Fixes */ 238 [data-ogsb] .bg-outer { background-color: #131314 !important; } 239 [data-ogsb] .bg-card { background-color: #1E1F20 !important; } 240 [data-ogsb] .bg-tint { background-color: #282A2C !important; } 241 [data-ogsc] .text-primary { color: #E3E3E3 !important; } 242 [data-ogsc] .text-secondary { color: #C4C7C5 !important; } 243 [data-ogsc] .text-accent { color: #A8C7FA !important; } 244 [data-ogsb] .border-card { border-color: #444746 !important; } 245 [data-ogsb] .divider { background-color: #444746 !important; } 246 [data-ogsb] .btn-pill { background-color: #A8C7FA !important; } 247 [data-ogsc] .btn-pill { color: #000000 !important; } 248 [data-ogsc] .icon-fill { fill: #8E918F !important; } 249 </style> 250 </head> 251 252 <body class="bg-outer" style="margin: 0; padding: 0; background-color: #F8F9FA; font-family: 'Google Sans', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased;"> 253 254 <table width="100%" border="0" cellspacing="0" cellpadding="0" class="bg-outer" style="background-color: #F8F9FA; width: 100%; height: 100%;"> 255 <tr> 256 <td align="center" style="padding: 40px 16px;"> 257 258 <table border="0" cellspacing="0" cellpadding="0" class="bg-card border-card" style="background-color: #FFFFFF; border-radius: 28px; max-width: 600px; width: 100%; overflow: hidden; border: 1px solid #C4C7C5;"> 259 260 <tr> 261 <td style="padding: 40px 32px 16px 32px;"> 262 <h2 class="text-primary" style="margin: 0; font-family: 'Google Sans', Roboto, sans-serif; font-size: 22px; font-weight: 500; color: #1F1F1F; letter-spacing: -0.5px;">Incoming Message</h2> 263 <p class="text-secondary" style="margin: 8px 0 0 0; font-family: Roboto, Arial, sans-serif; font-size: 14px; font-weight: 400; color: #444746; line-height: 1.5;">A new email has been routed to your developer inbox.</p> 264 </td> 265 </tr> 266 267 <tr> 268 <td style="padding: 0 32px;"> 269 <div class="divider" style="height: 1px; background-color: #E3E3E3; width: 100%; margin: 16px 0;"></div> 270 </td> 271 </tr> 272 273 <tr> 274 <td style="padding: 16px 32px;"> 275 276 <div style="margin-bottom: 24px;"> 277 <span class="text-accent" style="display: block; font-family: Roboto, Arial, sans-serif; font-size: 11px; font-weight: 500; color: #0B57D0; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px;">Sender Email ID</span> 278 <span class="text-primary" style="display: block; font-family: Roboto, Arial, sans-serif; font-size: 16px; font-weight: 400; color: #1F1F1F; word-break: break-all; line-height: 1.4;">${escapeHtml(originalSender)}</span> 279 </div> 280 281 <div style="margin-bottom: 24px;"> 282 <span class="text-accent" style="display: block; font-family: Roboto, Arial, sans-serif; font-size: 11px; font-weight: 500; color: #0B57D0; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px;">Subject</span> 283 <span class="text-primary" style="display: block; font-family: 'Google Sans', Roboto, Arial, sans-serif; font-size: 18px; font-weight: 500; color: #1F1F1F; line-height: 1.4;">${escapeHtml(subject)}</span> 284 </div> 285 286 <div style="margin-bottom: 32px;"> 287 <span class="text-accent" style="display: block; font-family: Roboto, Arial, sans-serif; font-size: 11px; font-weight: 500; color: #0B57D0; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px;">Email Tracking ID</span> 288 289 <table border="0" cellspacing="0" cellpadding="0" style="width: 100%;"> 290 <tr> 291 <td class="bg-tint" style="background-color: #F0F4F9; border-radius: 8px; padding: 12px 16px;"> 292 <span class="text-secondary" style="display: block; font-family: 'Roboto Mono', monospace; font-size: 14px; font-weight: 500; color: #444746; word-break: break-all;"> 293 ${escapeHtml(emailData.email_id || 'N/A')} 294 </span> 295 </td> 296 </tr> 297 </table> 298 </div> 299 300 </td> 301 </tr> 302 303 <tr> 304 <td style="padding: 8px 32px 40px 32px;"> 305 <table border="0" cellspacing="0" cellpadding="0"> 306 <tr> 307 <td align="center" style="border-radius: 100px;"> 308 <a href="https://resend.com/emails/${emailData.email_id}" target="_blank" class="btn-pill" style="display: inline-block; padding: 12px 32px; background-color: #0B57D0; font-family: 'Google Sans', Roboto, Arial, sans-serif; font-size: 14px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 100px;"> 309 Open Resend Dashboard 310 </a> 311 </td> 312 </tr> 313 </table> 314 </td> 315 </tr> 316 317 </table> 318 319 <table border="0" cellspacing="0" cellpadding="0" style="max-width: 600px; width: 100%; margin-top: 32px;"> 320 <tr> 321 <td align="center" style="padding-bottom: 24px;"> 322 <p class="text-secondary" style="margin: 0; font-family: Roboto, Arial, sans-serif; font-size: 12px; color: #747775; line-height: 1.6;">Automated alert by Notifier Bot.<br>Please do not reply to this unmonitored email.</p> 323 </td> 324 </tr> 325 326 <tr> 327 <td align="center" style="padding-bottom: 16px;"> 328 <table border="0" cellspacing="0" cellpadding="0"> 329 <tr> 330 <td style="padding: 0 12px;"> 331 <a href="https://amit.is-a.dev" target="_blank" style="text-decoration: none;" title="Website"> 332 <svg width="20" height="20" viewBox="0 0 24 24" fill="#747775" class="icon-fill" xmlns="http://www.w3.org/2000/svg" style="display: block; border: 0;"> 333 <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/> 334 </svg> 335 </a> 336 </td> 337 <td style="padding: 0 12px;"> 338 <a href="https://github.com/notamitgamer" target="_blank" style="text-decoration: none;" title="GitHub"> 339 <svg width="20" height="20" viewBox="0 0 16 16" fill="#747775" class="icon-fill" xmlns="http://www.w3.org/2000/svg" style="display: block; border: 0;"> 340 <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/> 341 </svg> 342 </a> 343 </td> 344 <td style="padding: 0 12px;"> 345 <a href="mailto:amitdutta4255@gmail.com" style="text-decoration: none;" title="Email"> 346 <svg width="20" height="20" viewBox="0 0 24 24" fill="#747775" class="icon-fill" xmlns="http://www.w3.org/2000/svg" style="display: block; border: 0;"> 347 <path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/> 348 </svg> 349 </a> 350 </td> 351 </tr> 352 </table> 353 </td> 354 </tr> 355 356 <tr> 357 <td align="center"> 358 <p class="text-secondary" style="margin: 0; font-family: Roboto, Arial, sans-serif; font-size: 12px; color: #747775;"> 359 © 2025-2026 Amit Dutta • <a href="https://amit.is-a.dev" class="text-secondary" style="color: #747775; text-decoration: none;">amit.is-a.dev</a> 360 </p> 361 </td> 362 </tr> 363 </table> 364 365 </td> 366 </tr> 367 </table> 368 369 </body> 370 </html> 371 `; 372 373 // Try to forward the real message. If anything about fetching or parsing it 374 // fails, fall back to the old "open the dashboard" notification so mail is 375 // never silently dropped. 376 let payload; 377 let parsed = null; 378 let fetchErrorMessage = null; 379 const created = new Date(emailData.created_at); 380 const receivedAt = Number.isNaN(created.getTime()) ? new Date() : created; 381 try { 382 parsed = await fetchFullEmail(emailData.email_id); 383 384 const replyTarget = parsed.replyTo?.text || originalSender; 385 const senderName = displayNameOf(originalSender); 386 387 const banner = buildBanner({ 388 from: originalSender, 389 to: originalRecipient, 390 receivedAt, 391 emailId: emailData.email_id, 392 subject, 393 replyTarget, 394 attachmentCount: (parsed.attachments || []).length, 395 }); 396 397 const bodyHtml = parsed.html 398 ? parsed.html 399 : `<pre style="font-family: Arial, sans-serif; white-space: pre-wrap;">${escapeHtml(parsed.text || '(empty message)')}</pre>`; 400 401 const attachments = (parsed.attachments || []).map((attachment) => ({ 402 filename: attachment.filename || 'attachment', 403 content: attachment.content.toString('base64'), 404 contentType: attachment.contentType, 405 contentId: attachment.contentId?.replace(/^<|>$/g, '') || undefined, 406 })); 407 408 payload = { 409 from: `"${senderName} via ${originalRecipient}" <${forwardingAddress}>`, 410 to: [personalGmail], 411 replyTo: replyTarget, // resend v6 ignores snake_case `reply_to` 412 subject: subject, 413 html: injectBanner(bodyHtml, banner), 414 text: `Sent to ${originalRecipient} by ${originalSender}\n\n${parsed.text || ''}`, 415 attachments: attachments.length > 0 ? attachments : undefined, 416 }; 417 } catch (fetchError) { 418 console.error('Falling back to notification card:', fetchError.message); 419 fetchErrorMessage = fetchError.message; 420 payload = { 421 from: `Notifier Bot <${forwardingAddress}>`, 422 to: [personalGmail], 423 replyTo: originalSender, 424 subject: subject, 425 html: buildNotificationHtml() 426 }; 427 } 428 429 // Archive in Firestore. A Firebase problem must never stop the mail reaching Gmail. 430 try { 431 await archiveEmail({ 432 emailId: emailData.email_id, 433 from: originalSender, 434 to: originalRecipient, 435 subject, 436 receivedAt, 437 parsed, 438 fetchError: fetchErrorMessage, 439 }); 440 } catch (archiveError) { 441 console.error('Firestore archive failed:', archiveError.message); 442 } 443 444 const { data, error } = await resend.emails.send(payload); 445 446 if (error) { 447 console.error(error); 448 return res.status(500).json({ error: error.message }); 449 } 450 451 return res.status(200).json({ success: true, id: data?.id }); 452 453 } catch (error) { 454 console.error(error); 455 return res.status(500).json({ error: 'Internal Server Error' }); 456 } 457 }); 458 459 app.get('/ping', (req, res) => { 460 res.status(200).send('Server is awake!'); 461 }); 462 463 // `node server.js` (Render) starts the server; requiring this file for tests does not. 464 if (require.main === module) { 465 app.listen(port, () => { 466 console.log(`Email router listening on port ${port}`); 467 }); 468 } 469 470 module.exports = { app, buildBanner, injectBanner, archiveEmail };