mail-server

Simple mail server with a mailbox for custom mail

git clone https://github.com/notamitgamer/mail-server.git
· rebuilt hourlyOpen on GitHub
root / server.js

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, '&lt;')
     17     .replace(/>/g, '&gt;')
     18     .replace(/"/g, '&quot;')
     19     .replace(/'/g, '&#39;');
     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> &lt;${escapeHtml(addressOf(from))}&gt;`)
     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                 &copy; 2025-2026 Amit Dutta &bull; <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 };