notamitgamer

It's my profile man, there is ...
Log | Files | Refs | Activity | README

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:
Mpackage.json | 6++----
Apublic/icon.svg | 5+++++
Apublic/index.html | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apublic/manifest.json | 17+++++++++++++++++
Apublic/sw.js | 23+++++++++++++++++++++++
Ascripts/backfill-from-resend.js | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mserver.js | 366+++++++++++++++++++++++++++++++++++++------------------------------------------
Asrc/firebase.js | 27+++++++++++++++++++++++++++
Asrc/sseManager.js | 25+++++++++++++++++++++++++
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()">&larr; 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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;"> - &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> - `; - - 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 };
© notamitgamer • Site Built: 2026-09-05 01:53:16 UTC • git-mirror commit: c170d72 [view raw info]
Originally created with stagit • modified by notamitgamer
Forked from github.com/notamitgamer/git-mirror