commit b8ea86541c73b78240ad617d3809b6489dcdc47e
parent 233a790e9445389e464bad118caa0461760b33c2
Author: Amit Dutta <mail@amit.is-a.dev>
Date: Thu, 3 Sep 2026 06:41:43 +0530
Merge pull request #5 from notamitgamer/feat/self-hosted-mailbox
feat: self-hosted PWA mailbox (Firestore + SSE) to replace Resend-only relay
Diffstat:
9 files changed, 508 insertions(+), 198 deletions(-)
diff --git a/package.json b/package.json
@@ -9,11 +9,9 @@
"dependencies": {
"cors": "^2.8.5",
"express": "^4.19.0",
- "mongoose": "^8.3.0",
+ "firebase-admin": "^12.1.0",
"resend": "^3.2.0",
- "svix": "^1.24.0",
- "react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "svix": "^1.24.0"
},
"engines": {
"node": ">=18.0.0"
diff --git a/public/icon.svg b/public/icon.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
+ <rect width="128" height="128" rx="28" fill="#0B57D0"/>
+ <path d="M24 40h80a4 4 0 0 1 4 4v40a4 4 0 0 1-4 4H24a4 4 0 0 1-4-4V44a4 4 0 0 1 4-4z" fill="#FFFFFF"/>
+ <path d="M24 40l40 30 40-30" fill="none" stroke="#0B57D0" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
diff --git a/public/index.html b/public/index.html
@@ -0,0 +1,155 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
+<title>Mailbox</title>
+<link rel="manifest" href="/manifest.json">
+<link rel="icon" href="/icon.svg">
+<meta name="theme-color" content="#0B57D0">
+<style>
+ :root { color-scheme: light dark; }
+ * { box-sizing: border-box; }
+ body {
+ margin: 0; font-family: Roboto, Arial, sans-serif;
+ background: #F8F9FA; color: #1F1F1F;
+ }
+ @media (prefers-color-scheme: dark) {
+ body { background: #131314; color: #E3E3E3; }
+ .row { border-color: #333 !important; }
+ .row:hover { background: #1E1F20 !important; }
+ header { background: #1E1F20 !important; border-color: #333 !important; }
+ }
+ header {
+ position: sticky; top: 0; z-index: 5;
+ display: flex; align-items: center; gap: 12px;
+ padding: 16px 20px; background: #fff; border-bottom: 1px solid #e3e3e3;
+ }
+ header h1 { font-size: 18px; margin: 0; font-weight: 500; }
+ .badge {
+ background: #0B57D0; color: #fff; border-radius: 100px;
+ font-size: 12px; padding: 2px 10px; margin-left: auto;
+ }
+ #list { max-width: 720px; margin: 0 auto; }
+ .row {
+ display: flex; flex-direction: column; gap: 4px;
+ padding: 14px 20px; border-bottom: 1px solid #eee; cursor: pointer;
+ }
+ .row:hover { background: #f0f4f9; }
+ .row .top { display: flex; justify-content: space-between; gap: 8px; font-size: 14px; }
+ .row .from { font-weight: 500; }
+ .row .subject { font-size: 14px; color: #555; }
+ .row.unread .subject, .row.unread .from { font-weight: 700; }
+ .empty { text-align: center; padding: 60px 20px; color: #888; }
+ #reader {
+ display: none; max-width: 720px; margin: 0 auto; padding: 20px;
+ }
+ #reader.open { display: block; }
+ #list.hidden { display: none; }
+ .back { background: none; border: none; color: #0B57D0; font-size: 14px; cursor: pointer; padding: 8px 0; }
+ #readerMeta { margin-bottom: 12px; }
+ #readerMeta .subj { font-size: 20px; font-weight: 500; margin: 8px 0; }
+ #readerMeta .from { font-size: 14px; color: #666; }
+ iframe { width: 100%; min-height: 60vh; border: none; }
+</style>
+</head>
+<body>
+
+<header>
+ <h1>📬 Mailbox</h1>
+ <span class="badge" id="count" style="display:none;"></span>
+</header>
+
+<div id="list"><div class="empty">Loading…</div></div>
+
+<div id="reader">
+ <button class="back" onclick="closeReader()">← Back to inbox</button>
+ <div id="readerMeta"></div>
+ <iframe id="readerFrame" sandbox=""></iframe>
+</div>
+
+<script>
+let mails = [];
+
+async function loadMails() {
+ const res = await fetch('/api/mails');
+ const data = await res.json();
+ mails = data.mails || [];
+ render();
+}
+
+function render() {
+ const list = document.getElementById('list');
+ const count = document.getElementById('count');
+ const unread = mails.filter(m => !m.read).length;
+ count.style.display = unread ? 'inline-block' : 'none';
+ count.textContent = unread + ' new';
+
+ if (!mails.length) {
+ list.innerHTML = '<div class="empty">No mail yet. It will show up here the moment it arrives.</div>';
+ return;
+ }
+
+ list.innerHTML = mails.map(m => `
+ <div class="row ${m.read ? '' : 'unread'}" onclick="openMail('${m.id}')">
+ <div class="top">
+ <span class="from">${escapeHtml(m.from || 'Unknown sender')}</span>
+ <span class="time">${m.receivedAt ? new Date(m.receivedAt).toLocaleString() : ''}</span>
+ </div>
+ <div class="subject">${escapeHtml(m.subject || '(no subject)')}</div>
+ </div>
+ `).join('');
+}
+
+async function openMail(id) {
+ const res = await fetch('/api/mails/' + id);
+ const mail = await res.json();
+
+ document.getElementById('readerMeta').innerHTML = `
+ <div class="from"><strong>From:</strong> ${escapeHtml(mail.from || '')}</div>
+ <div class="from"><strong>To:</strong> ${escapeHtml(mail.to || '')}</div>
+ <div class="subj">${escapeHtml(mail.subject || '(no subject)')}</div>
+ `;
+
+ const frame = document.getElementById('readerFrame');
+ frame.srcdoc = mail.html || `<pre style="white-space:pre-wrap;font-family:Roboto,Arial,sans-serif;">${escapeHtml(mail.text || '(empty message)')}</pre>`;
+
+ document.getElementById('list').classList.add('hidden');
+ document.getElementById('reader').classList.add('open');
+
+ const idx = mails.findIndex(m => m.id === id);
+ if (idx > -1) mails[idx].read = true;
+}
+
+function closeReader() {
+ document.getElementById('list').classList.remove('hidden');
+ document.getElementById('reader').classList.remove('open');
+ render();
+}
+
+function escapeHtml(str) {
+ return String(str).replace(/[&<>"']/g, s => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[s]));
+}
+
+function connectStream() {
+ const es = new EventSource('/api/stream');
+ es.addEventListener('new_mail', (e) => {
+ const mail = JSON.parse(e.data);
+ mails.unshift({ ...mail, read: false });
+ render();
+ });
+ es.onerror = () => {
+ es.close();
+ setTimeout(connectStream, 5000);
+ };
+}
+
+if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.register('/sw.js').catch(() => {});
+}
+
+loadMails();
+connectStream();
+</script>
+</body>
+</html>
diff --git a/public/manifest.json b/public/manifest.json
@@ -0,0 +1,17 @@
+{
+ "name": "Amit's Mailbox",
+ "short_name": "Mailbox",
+ "description": "Self-hosted inbox for mail@amit.is-a.dev",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#F8F9FA",
+ "theme_color": "#0B57D0",
+ "icons": [
+ {
+ "src": "/icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/public/sw.js b/public/sw.js
@@ -0,0 +1,23 @@
+const CACHE = 'mailbox-shell-v1';
+const SHELL_ASSETS = ['/', '/index.html', '/icon.svg', '/manifest.json'];
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches.open(CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
+ );
+ self.skipWaiting();
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(self.clients.claim());
+});
+
+self.addEventListener('fetch', (event) => {
+ const { request } = event;
+ // Never cache API calls or the SSE stream — always go to network for those.
+ if (request.url.includes('/api/')) return;
+
+ event.respondWith(
+ caches.match(request).then((cached) => cached || fetch(request))
+ );
+});
diff --git a/scripts/backfill-from-resend.js b/scripts/backfill-from-resend.js
@@ -0,0 +1,82 @@
+/**
+ * One-off backfill: pulls every email Resend has already received
+ * (visible in the Resend dashboard under Emails > Receiving) and stores
+ * it in Firestore using the same schema server.js writes on new mail.
+ *
+ * Resend keeps a log of received emails independent of your webhook, so
+ * this recovers everything that arrived before this mailbox existed.
+ *
+ * Usage (from the repo root, with deps installed):
+ * RESEND_API_KEY=re_xxx FIREBASE_SERVICE_ACCOUNT='{...}' node scripts/backfill-from-resend.js
+ *
+ * Safe to re-run: emails are stored under a deterministic Firestore doc ID
+ * derived from the Resend email ID, so re-running just overwrites the same
+ * docs instead of duplicating them.
+ */
+
+const { Resend } = require('resend');
+const { db } = require('../src/firebase');
+
+const resend = new Resend(process.env.RESEND_API_KEY);
+const mailsCollection = db.collection('mails');
+
+async function backfill() {
+ if (!process.env.RESEND_API_KEY) {
+ console.error('Set RESEND_API_KEY before running this script.');
+ process.exit(1);
+ }
+
+ let cursor;
+ let total = 0;
+ let page = 0;
+
+ do {
+ page += 1;
+ const { data, error } = await resend.emails.receiving.list(
+ cursor ? { before: cursor } : undefined
+ );
+
+ if (error) {
+ console.error('Failed to list received emails:', error.message);
+ process.exit(1);
+ }
+
+ const items = data?.data || [];
+ console.log(`Page ${page}: ${items.length} emails`);
+
+ for (const summary of items) {
+ const { data: full, error: getError } = await resend.emails.receiving.get(summary.id);
+ if (getError) {
+ console.error(`Skipping ${summary.id}: ${getError.message}`);
+ continue;
+ }
+
+ const docId = `resend_${summary.id}`;
+ await mailsCollection.doc(docId).set({
+ from: full.from,
+ to: (full.to && full.to[0]) ? full.to[0].toLowerCase() : '',
+ subject: full.subject || 'No Subject',
+ html: full.html || null,
+ text: full.text || null,
+ resendEmailId: full.id,
+ read: true, // treat backfilled mail as already-seen
+ receivedAt: full.created_at ? new Date(full.created_at) : new Date(),
+ backfilled: true,
+ }, { merge: true });
+
+ total += 1;
+ process.stdout.write(` stored ${full.subject || '(no subject)'}\n`);
+ }
+
+ cursor = data?.has_more ? items[items.length - 1]?.id : null;
+ } while (cursor);
+
+ console.log(`\nDone. Backfilled ${total} email(s) into Firestore.`);
+ console.log('Restart the server (or wait for its next cold start) to pick these up in the list cache.');
+ process.exit(0);
+}
+
+backfill().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/server.js b/server.js
@@ -1,13 +1,58 @@
+const path = require('path');
const express = require('express');
const { Resend } = require('resend');
const { Webhook } = require('svix');
+const { db, admin } = require('./src/firebase');
+const sse = require('./src/sseManager');
+
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;
+// Optional: still ping your personal Gmail when mail arrives. If unset, this
+// step is just skipped and the mailbox is the sole source of truth.
+const personalGmail = process.env.PERSONAL_GMAIL;
+const forwardingAddress = process.env.FORWARDING_BOT_ADDRESS;
+
+const mailsCollection = () => db.collection('mails');
+
+// --- In-memory cache ---
+// Once warm, list/detail reads are served straight from this process's
+// memory instead of hitting Firestore on every device/tab that opens the
+// mailbox. Firestore is only touched on a genuine cache miss (cold start,
+// after a Render restart) or a real write (new mail / delete).
+let mailListCache = null; // array of list-row objects, newest first
+const mailDetailCache = new Map(); // id -> full mail object
+
+function toListRow(id, d) {
+ return {
+ id,
+ from: d.from,
+ to: d.to,
+ subject: d.subject,
+ read: d.read,
+ receivedAt: d.receivedAt ? d.receivedAt.toMillis() : null,
+ };
+}
+
+function toDetail(id, d) {
+ return {
+ id,
+ from: d.from,
+ to: d.to,
+ subject: d.subject,
+ html: d.html,
+ text: d.text,
+ read: d.read,
+ receivedAt: d.receivedAt ? d.receivedAt.toMillis() : null,
+ };
+}
+
+app.use(express.static(path.join(__dirname, 'public')));
+
app.post('/api/incoming', express.text({ type: 'application/json' }), async (req, res) => {
try {
const rawBody = req.body;
@@ -19,7 +64,7 @@ app.post('/api/incoming', express.text({ type: 'application/json' }), async (req
const wh = new Webhook(webhookSecret);
let event;
-
+
try {
event = wh.verify(rawBody, headers);
} catch (err) {
@@ -33,225 +78,158 @@ app.post('/api/incoming', express.text({ type: 'application/json' }), async (req
const emailData = event.data;
const originalSender = emailData.from;
- const originalRecipient = emailData.to[0].toLowerCase();
+ const originalRecipient = (emailData.to && emailData.to[0] ? 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());
+ const allowedAliases = allowedString.split(',').map(alias => alias.trim().toLowerCase()).filter(Boolean);
- if (!allowedAliases.includes(originalRecipient)) {
+ if (allowedAliases.length && !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
- const notificationHtml = `
-<!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;
+ // --- 1. Store the full email as a real mailbox entry ---
+ const mailDoc = {
+ from: originalSender,
+ to: originalRecipient,
+ subject,
+ html: emailData.html || null,
+ text: emailData.text || null,
+ resendEmailId: emailData.email_id || null,
+ read: false,
+ receivedAt: admin.firestore.FieldValue.serverTimestamp(),
+ };
+
+ const docRef = await mailsCollection().add(mailDoc);
+
+ // Warm the caches with this new mail immediately — no Firestore
+ // round-trip needed for the next read from any device.
+ const cachedDoc = { ...mailDoc, receivedAt: { toMillis: () => Date.now() } };
+ if (mailListCache) {
+ mailListCache.unshift(toListRow(docRef.id, cachedDoc));
+ }
+ mailDetailCache.set(docRef.id, toDetail(docRef.id, cachedDoc));
+
+ // Push live update to any open mailbox tab
+ sse.broadcast('new_mail', {
+ id: docRef.id,
+ from: originalSender,
+ to: originalRecipient,
+ subject,
+ receivedAt: Date.now(),
+ });
+
+ // --- 2. Optional: fire off a lightweight notification email ---
+ if (personalGmail && forwardingAddress) {
+ const notificationHtml = `
+ <div style="font-family:Roboto,Arial,sans-serif;max-width:480px;margin:0 auto;padding:24px;border:1px solid #e3e3e3;border-radius:16px;">
+ <h2 style="margin:0 0 8px;">New mail in your inbox</h2>
+ <p style="margin:0 0 4px;color:#444;"><strong>From:</strong> ${originalSender}</p>
+ <p style="margin:0 0 16px;color:#444;"><strong>Subject:</strong> ${subject}</p>
+ <p style="margin:0;color:#747775;font-size:13px;">Open your mailbox app to read the full message.</p>
+ </div>`;
+
+ const { error } = await resend.emails.send({
+ from: `Notifier Bot <${forwardingAddress}>`,
+ to: [personalGmail],
+ reply_to: originalSender,
+ subject: `New mail: ${subject}`,
+ html: notificationHtml,
+ });
+
+ if (error) console.error('Notification send failed:', error.message);
+ }
+
+ return res.status(200).json({ success: true, id: docRef.id });
+
+ } catch (error) {
+ console.error(error);
+ return res.status(500).json({ error: 'Internal Server Error' });
+ }
+});
+
+// --- Mailbox API (public, no auth by design) ---
+
+app.get('/api/mails', async (req, res) => {
+ try {
+ if (mailListCache) {
+ return res.json({ mails: mailListCache, cached: true });
}
- .btn-pill:hover {
- opacity: 0.9;
- transform: translateY(-1px);
- transition: all 0.2s ease;
+ const snapshot = await mailsCollection().orderBy('receivedAt', 'desc').limit(100).get();
+ mailListCache = snapshot.docs.map(doc => toListRow(doc.id, doc.data()));
+ res.json({ mails: mailListCache, cached: false });
+ } catch (error) {
+ console.error(error);
+ res.status(500).json({ error: 'Failed to fetch mails' });
+ }
+});
+
+app.get('/api/mails/:id', async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ let mail = mailDetailCache.get(id);
+ let fromCache = true;
+
+ if (!mail) {
+ fromCache = false;
+ const doc = await mailsCollection().doc(id).get();
+ if (!doc.exists) return res.status(404).json({ error: 'Not found' });
+ mail = toDetail(doc.id, doc.data());
+ mailDetailCache.set(id, mail);
}
- @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;
+ if (!mail.read) {
+ mail = { ...mail, read: true };
+ mailDetailCache.set(id, mail);
+ if (mailListCache) {
+ const row = mailListCache.find(m => m.id === id);
+ if (row) row.read = true;
}
- .icon-fill { fill: #8E918F !important; }
+ mailsCollection().doc(id).update({ read: true }).catch(() => {});
}
- /* 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;">${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;">${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;">
- ${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;">
- © 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>
- </p>
- </td>
- </tr>
- </table>
-
- </td>
- </tr>
- </table>
-
-</body>
-</html>
- `;
-
- const payload = {
- from: `Notifier Bot <${forwardingAddress}>`,
- to: [personalGmail],
- reply_to: originalSender,
- subject: subject,
- html: notificationHtml
- };
+ res.json({ ...mail, cached: fromCache });
+ } catch (error) {
+ console.error(error);
+ res.status(500).json({ error: 'Failed to fetch mail' });
+ }
+});
- const { data, error } = await resend.emails.send(payload);
+app.delete('/api/mails/:id', async (req, res) => {
+ try {
+ const { id } = req.params;
+ await mailsCollection().doc(id).delete();
- if (error) {
- console.error(error);
- return res.status(500).json({ error: error.message });
+ mailDetailCache.delete(id);
+ if (mailListCache) {
+ mailListCache = mailListCache.filter(m => m.id !== id);
}
- return res.status(200).json({ success: true, id: data?.id });
-
+ res.json({ success: true });
} catch (error) {
console.error(error);
- return res.status(500).json({ error: 'Internal Server Error' });
+ res.status(500).json({ error: 'Failed to delete mail' });
}
});
+// --- Live updates ---
+app.get('/api/stream', (req, res) => {
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive',
+ });
+ res.write('\n');
+ sse.addClient(res);
+});
+
app.get('/ping', (req, res) => {
res.status(200).send('Server is awake!');
});
app.listen(port, () => {
- console.log(`Email router listening on port ${port}`);
+ sse.startHeartbeat();
+ console.log(`Mailbox server listening on port ${port}`);
});
diff --git a/src/firebase.js b/src/firebase.js
@@ -0,0 +1,27 @@
+const admin = require('firebase-admin');
+
+// --- FIREBASE SETUP ---
+// Same pattern as WhatsApp-Logger-Self-Hosted-: pass the full service account
+// JSON as a single env var on Render (FIREBASE_SERVICE_ACCOUNT), or fall back
+// to a local serviceAccountKey.json for local dev.
+let serviceAccount;
+try {
+ if (process.env.FIREBASE_SERVICE_ACCOUNT) {
+ serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT);
+ } else {
+ serviceAccount = require('../serviceAccountKey.json');
+ }
+
+ admin.initializeApp({
+ credential: admin.credential.cert(serviceAccount)
+ });
+ console.log('System: Firebase Admin initialized successfully.');
+} catch (error) {
+ console.error('System Error: Failed to initialize Firebase. Make sure FIREBASE_SERVICE_ACCOUNT env var is set.');
+ console.error(error.message);
+ process.exit(1);
+}
+
+const db = admin.firestore();
+
+module.exports = { admin, db };
diff --git a/src/sseManager.js b/src/sseManager.js
@@ -0,0 +1,25 @@
+// --- SSE CONNECTION MANAGER ---
+// Mirrors the pattern used in WhatsApp-Logger-Self-Hosted-/src/sseManager.js
+const clients = new Set();
+
+function addClient(res) {
+ clients.add(res);
+ res.on('close', () => clients.delete(res));
+}
+
+function broadcast(event, data) {
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
+ for (const res of clients) {
+ try { res.write(payload); } catch (e) { /* client gone */ }
+ }
+}
+
+function startHeartbeat() {
+ setInterval(() => {
+ for (const res of clients) {
+ try { res.write(': ping\n\n'); } catch (e) { /* client gone */ }
+ }
+ }, 25000);
+}
+
+module.exports = { addClient, broadcast, startHeartbeat };