mail-server

Simple mail server with a mailbox for custom mail

git clone https://github.com/notamitgamer/mail-server.git
· rebuilt hourlyOpen on GitHub
commit 509cb17e0978d97b7e46f2364d888c4e79c0b4c0
Author: Amit Dutta <mail@amit.is-a.dev>
Date:   Tue, 20 Oct 2026 17:05:34 +0530

Initialize email router with Express and Firebase
Diffstat:
Aserver.js | 470+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 470 insertions(+), 0 deletions(-)

diff --git a/server.js b/server.js @@ -0,0 +1,470 @@ +const express = require('express'); +const { Resend } = require('resend'); +const { Webhook } = require('svix'); +const { simpleParser } = require('mailparser'); +const admin = require('firebase-admin'); + +const app = express(); +const port = process.env.PORT || 3000; + +const resend = new Resend(process.env.RESEND_API_KEY); +const webhookSecret = process.env.RESEND_WEBHOOK_SECRET; + +const escapeHtml = (value) => + String(value ?? '') + .replace(/&/g, '&amp;') + .replace(/</g, '&lt;') + .replace(/>/g, '&gt;') + .replace(/"/g, '&quot;') + .replace(/'/g, '&#39;'); + +// Pull a display name out of `"Jane Doe" <jane@example.com>`; fall back to the address. +// The result is stripped of characters that could break a From header. +const displayNameOf = (from) => { + const match = String(from || '').match(/^\s*(?:"([^"]*)"|([^<]+?))\s*<([^>]+)>\s*$/); + const name = match ? (match[1] || match[2] || match[3]) : String(from || 'Unknown sender'); + return name.replace(/[<>"\\\r\n]/g, '').trim() || 'Unknown sender'; +}; + +// Bare address from `"Jane" <jane@example.com>` or `jane@example.com`. +const addressOf = (value) => { + const match = String(value || '').match(/<([^>]+)>/); + return (match ? match[1] : String(value || '')).trim(); +}; + +const DISPLAY_TZ = process.env.DISPLAY_TZ || 'Asia/Kolkata'; +const formatReceived = (date) => + new Intl.DateTimeFormat('en-GB', { + timeZone: DISPLAY_TZ, + weekday: 'short', day: '2-digit', month: 'short', year: 'numeric', + hour: '2-digit', minute: '2-digit', hour12: true, timeZoneName: 'short', + }).format(date).replace('GMT+5:30', 'IST'); + +// Info banner shown above the original message: who sent it, where it arrived, +// when, a trace link into Resend, and a Reply button. Inline styles only, so it +// survives email clients. +const buildBanner = ({ from, to, receivedAt, emailId, subject, replyTarget, attachmentCount }) => { + const replySubject = /^re:/i.test(subject) ? subject : `Re: ${subject}`; + const replyHref = `mailto:${encodeURI(addressOf(replyTarget))}?subject=${encodeURIComponent(replySubject)}`; + const traceHref = `https://resend.com/emails/${encodeURIComponent(emailId)}`; + + 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;'; + const value = 'padding: 6px 0; vertical-align: top; font-size: 14px; line-height: 1.4; color: #1f1f1f; word-break: break-word;'; + + const row = (name, content) => + `<tr><td style="${label}">${name}</td><td style="${value}">${content}</td></tr>`; + + const attachmentRow = attachmentCount > 0 + ? row('Files', `${attachmentCount} attachment${attachmentCount === 1 ? '' : 's'} (included below)`) + : ''; + + return `<div style="max-width: 720px; margin: 0 0 20px 0; font-family: Roboto, Arial, Helvetica, sans-serif;">` + + `<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;">` + + `<tr><td style="padding: 16px 20px 4px 20px;">` + + `<div style="font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: #0b57d0; margin-bottom: 8px;">Incoming mail</div>` + + `<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">` + + row('From', `<b>${escapeHtml(displayNameOf(from))}</b> &lt;${escapeHtml(addressOf(from))}&gt;`) + + row('To', escapeHtml(to)) + + row('Received', escapeHtml(formatReceived(receivedAt))) + + 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>`) + + attachmentRow + + `</table></td></tr>` + + `<tr><td style="padding: 8px 20px 18px 20px;">` + + `<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>` + + `<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>` + + `</td></tr></table></div>`; +}; + +// Put the banner just inside the original message's <body>, so the result is +// still one valid HTML document. Messages without a <body> get it prepended. +const injectBanner = (html, banner) => { + const bodyOpen = /<body[^>]*>/i; + return bodyOpen.test(html) ? html.replace(bodyOpen, (tag) => tag + banner) : banner + html; +}; + +// ---- Firestore archive (optional) ------------------------------------------- +// Set FIREBASE_SERVICE_ACCOUNT to the service-account JSON (raw or base64). +// Without it the server still forwards mail; it just skips the archive. +let db = null; +try { + const rawAccount = process.env.FIREBASE_SERVICE_ACCOUNT; + if (rawAccount) { + const json = rawAccount.trim().startsWith('{') + ? rawAccount + : Buffer.from(rawAccount, 'base64').toString('utf8'); + admin.initializeApp({ credential: admin.credential.cert(JSON.parse(json)) }); + db = admin.firestore(); + } else { + console.warn('FIREBASE_SERVICE_ACCOUNT not set - mail will not be archived'); + } +} catch (err) { + console.error('Firebase init failed - mail will not be archived:', err.message); +} + +// Firestore documents are capped at ~1 MiB, so keep each body field well under it. +const MAX_BODY_CHARS = 250000; +const capBody = (value) => (value ? value.slice(0, MAX_BODY_CHARS) : null); + +// Save one received mail, keyed by Resend's email ID. create() fails if the +// document exists, so a webhook retry never duplicates it or resets `read`. +const archiveEmail = async ({ emailId, from, to, subject, receivedAt, parsed, fetchError }) => { + if (!db) return; + try { + await db.collection('mails').doc(emailId).create({ + from, + to, + subject, + html: capBody(parsed?.html), + text: capBody(parsed?.text), + truncated: (parsed?.html?.length || 0) > MAX_BODY_CHARS || (parsed?.text?.length || 0) > MAX_BODY_CHARS, + receivedAt: admin.firestore.Timestamp.fromDate(receivedAt), + resendEmailId: emailId, + read: false, + messageId: parsed?.messageId || null, + inReplyTo: parsed?.inReplyTo || null, + references: parsed?.references ? [].concat(parsed.references) : [], + attachments: (parsed?.attachments || []).map((a) => ({ + filename: a.filename || 'attachment', + contentType: a.contentType || null, + size: a.size || 0, + })), + fetchError: fetchError || null, + }); + } catch (err) { + if (err.code === 6 || /already exists/i.test(err.message)) return; // retry of a mail we already saved + throw err; + } +}; + +// Resend's email.received webhook only carries metadata (no body, headers or +// attachments). Fetch the message itself via the Received Emails API. +const fetchFullEmail = async (emailId) => { + const { data: received, error } = await resend.emails.receiving.get(emailId); + if (error) throw new Error(`Could not retrieve received email: ${error.message}`); + + const downloadUrl = received?.raw?.download_url; + if (!downloadUrl) throw new Error('Received email has no raw download URL'); + + const rawResponse = await fetch(downloadUrl); + if (!rawResponse.ok) throw new Error(`Raw email download failed (${rawResponse.status})`); + + const raw = Buffer.from(await rawResponse.arrayBuffer()); + return simpleParser(raw, { skipImageLinks: true }); +}; + +app.post('/api/incoming', express.text({ type: 'application/json' }), async (req, res) => { + try { + const rawBody = req.body; + const headers = { + 'svix-id': req.headers['svix-id'], + 'svix-timestamp': req.headers['svix-timestamp'], + 'svix-signature': req.headers['svix-signature'], + }; + + const wh = new Webhook(webhookSecret); + let event; + + try { + event = wh.verify(rawBody, headers); + } catch (err) { + console.error(err.message); + return res.status(400).json({ error: 'Invalid signature' }); + } + + if (event.type !== 'email.received') { + return res.status(200).json({ message: 'Not an email event' }); + } + + const emailData = event.data; + const originalSender = emailData.from; + const originalRecipient = emailData.to[0].toLowerCase(); + const subject = emailData.subject || 'No Subject'; + + const allowedString = process.env.ALLOWED_ALIASES || ''; + const allowedAliases = allowedString.split(',').map(alias => alias.trim().toLowerCase()); + + if (!allowedAliases.includes(originalRecipient)) { + return res.status(200).json({ success: true, message: 'Alias ignored' }); + } + + const personalGmail = process.env.PERSONAL_GMAIL; + const forwardingAddress = process.env.FORWARDING_BOT_ADDRESS; + + // Premium Adaptive Google-style HTML Template. + // Only used as a fallback now, if the full message cannot be fetched. + const buildNotificationHtml = () => ` +<!DOCTYPE html> +<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta name="color-scheme" content="light dark"> + <meta name="supported-color-schemes" content="light dark"> + <title>New Email Notification</title> + + <style> + @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'); + + body { + margin: 0; + padding: 0; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + .btn-pill:hover { + opacity: 0.9; + transform: translateY(-1px); + transition: all 0.2s ease; + } + + @media (prefers-color-scheme: dark) { + .bg-outer { background-color: #131314 !important; } + .bg-card { background-color: #1E1F20 !important; } + .bg-tint { background-color: #282A2C !important; } + .text-primary { color: #E3E3E3 !important; } + .text-secondary { color: #C4C7C5 !important; } + .text-accent { color: #A8C7FA !important; } + .border-card { border-color: #444746 !important; } + .divider { background-color: #444746 !important; } + .btn-pill { + background-color: #A8C7FA !important; + color: #000000 !important; + } + .icon-fill { fill: #8E918F !important; } + } + + /* Gmail Dark Mode Fixes */ + [data-ogsb] .bg-outer { background-color: #131314 !important; } + [data-ogsb] .bg-card { background-color: #1E1F20 !important; } + [data-ogsb] .bg-tint { background-color: #282A2C !important; } + [data-ogsc] .text-primary { color: #E3E3E3 !important; } + [data-ogsc] .text-secondary { color: #C4C7C5 !important; } + [data-ogsc] .text-accent { color: #A8C7FA !important; } + [data-ogsb] .border-card { border-color: #444746 !important; } + [data-ogsb] .divider { background-color: #444746 !important; } + [data-ogsb] .btn-pill { background-color: #A8C7FA !important; } + [data-ogsc] .btn-pill { color: #000000 !important; } + [data-ogsc] .icon-fill { fill: #8E918F !important; } + </style> +</head> + +<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;"> + + <table width="100%" border="0" cellspacing="0" cellpadding="0" class="bg-outer" style="background-color: #F8F9FA; width: 100%; height: 100%;"> + <tr> + <td align="center" style="padding: 40px 16px;"> + + <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;"> + + <tr> + <td style="padding: 40px 32px 16px 32px;"> + <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> + <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> + </td> + </tr> + + <tr> + <td style="padding: 0 32px;"> + <div class="divider" style="height: 1px; background-color: #E3E3E3; width: 100%; margin: 16px 0;"></div> + </td> + </tr> + + <tr> + <td style="padding: 16px 32px;"> + + <div style="margin-bottom: 24px;"> + <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> + <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> + </div> + + <div style="margin-bottom: 24px;"> + <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> + <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> + </div> + + <div style="margin-bottom: 32px;"> + <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> + + <table border="0" cellspacing="0" cellpadding="0" style="width: 100%;"> + <tr> + <td class="bg-tint" style="background-color: #F0F4F9; border-radius: 8px; padding: 12px 16px;"> + <span class="text-secondary" style="display: block; font-family: 'Roboto Mono', monospace; font-size: 14px; font-weight: 500; color: #444746; word-break: break-all;"> + ${escapeHtml(emailData.email_id || 'N/A')} + </span> + </td> + </tr> + </table> + </div> + + </td> + </tr> + + <tr> + <td style="padding: 8px 32px 40px 32px;"> + <table border="0" cellspacing="0" cellpadding="0"> + <tr> + <td align="center" style="border-radius: 100px;"> + <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;"> + Open Resend Dashboard + </a> + </td> + </tr> + </table> + </td> + </tr> + + </table> + + <table border="0" cellspacing="0" cellpadding="0" style="max-width: 600px; width: 100%; margin-top: 32px;"> + <tr> + <td align="center" style="padding-bottom: 24px;"> + <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> + </td> + </tr> + + <tr> + <td align="center" style="padding-bottom: 16px;"> + <table border="0" cellspacing="0" cellpadding="0"> + <tr> + <td style="padding: 0 12px;"> + <a href="https://amit.is-a.dev" target="_blank" style="text-decoration: none;" title="Website"> + <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;"> + <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"/> + </svg> + </a> + </td> + <td style="padding: 0 12px;"> + <a href="https://github.com/notamitgamer" target="_blank" style="text-decoration: none;" title="GitHub"> + <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;"> + <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"/> + </svg> + </a> + </td> + <td style="padding: 0 12px;"> + <a href="mailto:amitdutta4255@gmail.com" style="text-decoration: none;" title="Email"> + <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;"> + <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"/> + </svg> + </a> + </td> + </tr> + </table> + </td> + </tr> + + <tr> + <td align="center"> + <p class="text-secondary" style="margin: 0; font-family: Roboto, Arial, sans-serif; font-size: 12px; color: #747775;"> + &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> + </p> + </td> + </tr> + </table> + + </td> + </tr> + </table> + +</body> +</html> + `; + + // Try to forward the real message. If anything about fetching or parsing it + // fails, fall back to the old "open the dashboard" notification so mail is + // never silently dropped. + let payload; + let parsed = null; + let fetchErrorMessage = null; + const created = new Date(emailData.created_at); + const receivedAt = Number.isNaN(created.getTime()) ? new Date() : created; + try { + parsed = await fetchFullEmail(emailData.email_id); + + const replyTarget = parsed.replyTo?.text || originalSender; + const senderName = displayNameOf(originalSender); + + const banner = buildBanner({ + from: originalSender, + to: originalRecipient, + receivedAt, + emailId: emailData.email_id, + subject, + replyTarget, + attachmentCount: (parsed.attachments || []).length, + }); + + const bodyHtml = parsed.html + ? parsed.html + : `<pre style="font-family: Arial, sans-serif; white-space: pre-wrap;">${escapeHtml(parsed.text || '(empty message)')}</pre>`; + + const attachments = (parsed.attachments || []).map((attachment) => ({ + filename: attachment.filename || 'attachment', + content: attachment.content.toString('base64'), + contentType: attachment.contentType, + contentId: attachment.contentId?.replace(/^<|>$/g, '') || undefined, + })); + + payload = { + from: `"${senderName} via ${originalRecipient}" <${forwardingAddress}>`, + to: [personalGmail], + replyTo: replyTarget, // resend v6 ignores snake_case `reply_to` + subject: subject, + html: injectBanner(bodyHtml, banner), + text: `Sent to ${originalRecipient} by ${originalSender}\n\n${parsed.text || ''}`, + attachments: attachments.length > 0 ? attachments : undefined, + }; + } catch (fetchError) { + console.error('Falling back to notification card:', fetchError.message); + fetchErrorMessage = fetchError.message; + payload = { + from: `Notifier Bot <${forwardingAddress}>`, + to: [personalGmail], + replyTo: originalSender, + subject: subject, + html: buildNotificationHtml() + }; + } + + // Archive in Firestore. A Firebase problem must never stop the mail reaching Gmail. + try { + await archiveEmail({ + emailId: emailData.email_id, + from: originalSender, + to: originalRecipient, + subject, + receivedAt, + parsed, + fetchError: fetchErrorMessage, + }); + } catch (archiveError) { + console.error('Firestore archive failed:', archiveError.message); + } + + const { data, error } = await resend.emails.send(payload); + + if (error) { + console.error(error); + return res.status(500).json({ error: error.message }); + } + + return res.status(200).json({ success: true, id: data?.id }); + + } catch (error) { + console.error(error); + return res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +app.get('/ping', (req, res) => { + res.status(200).send('Server is awake!'); +}); + +// `node server.js` (Render) starts the server; requiring this file for tests does not. +if (require.main === module) { + app.listen(port, () => { + console.log(`Email router listening on port ${port}`); + }); +} + +module.exports = { app, buildBanner, injectBanner, archiveEmail };