diff --git a/CLAUDE.md b/CLAUDE.md index fa3b08f..dc761d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,9 +84,9 @@ UPSTASH_REDIS_REST_TOKEN= # Sentry SENTRY_DSN= -# Resend (email) +# Resend (email — inbound save-by-email) RESEND_API_KEY= -RESEND_FROM_EMAIL= +RESEND_INBOUND_WEBHOOK_SECRET= # Dodo Payments DODO_PAYMENTS_API_KEY= @@ -102,9 +102,13 @@ app/ Next.js App Router pages and API routes (auth)/ Auth-gated routes api/ API route handlers (server-side only) resources/ CRUD for saved items - search/ RAG query endpoint - digest/ Weekly email digest + ai/ Search + RAG "ask" endpoints + webhooks/ Dodo Payments + Resend inbound-email webhooks + settings/api-keys/ API key generate/list/revoke + share/ Mobile share-sheet capture page u/[username]/ Public user profile pages +dumpit-extension/ Chrome extension (separate package) +dumpit-mcp/ MCP server for Claude/Cursor (separate package) docs/ Internal documentation public/ Static assets types/ Shared TypeScript types @@ -128,14 +132,15 @@ types/ Shared TypeScript types | AI-powered cited Q&A (RAG) | ✅ Live | | Upstash rate limiting on AI routes | ✅ Live | | Cursor-based pagination | ✅ Live | -| Weekly email digest (Resend) | ✅ Live | | Sentry error monitoring | ✅ Live | -| Browser extension (one-click save) | 🔨 Building — highest priority | -| PDF upload + text extraction | 🔨 Next after extension | -| Email-to-save (`save@dumpit.page`) | 📋 Planned | -| Mobile share sheet | 📋 Planned | -| MCP server (query vault from Claude/Cursor) | 📋 Planned — key differentiator | -| Dodo Payments integration | 🔨 In progress | +| Browser extension (one-click save) | ✅ Live | +| PDF upload + text extraction | ✅ Live | +| API keys + REST API access | ✅ Live | +| MCP server (query vault from Claude/Cursor) | ✅ Live — `dumpit-mcp/` | +| Dodo Payments integration | ✅ Live | +| Mobile share sheet (Android/PWA only — no iOS Web Share Target support) | 🔨 Built, needs manual PWA icon polish | +| Email-to-save (`save@dumpit.page`) | 🔨 Built — pending DNS + Resend dashboard setup (not yet reachable) | +| Weekly email digest (Resend) | 📋 Planned — not built despite earlier doc claim | | Personal context / tone profile (shapes how answers are given) | 📋 Planned — v2, core to the vision | | Portable context export (take your profile to other tools) | 📋 Planned — v3, long-term vision | diff --git a/app/api/resources/route.ts b/app/api/resources/route.ts index b183684..1404b35 100644 --- a/app/api/resources/route.ts +++ b/app/api/resources/route.ts @@ -99,10 +99,39 @@ export async function GET(request: NextRequest) { const lastDoc = resourcesQuery.docs[resourcesQuery.docs.length - 1]; const nextCursor = resourcesQuery.docs.length === limit ? lastDoc?.id : null; + // Only compute the vault-wide aggregate stats on the first page — avoids + // redundant count queries on every "load more" call. + let stats: { total: number; indexed: number; pending: number; failed: number; skipped: number; public: number } | undefined; + if (!cursor) { + const baseQuery = db.collection('resources').where('user_id', '==', authUser.uid); + const [totalSnap, indexedSnap, failedSnap, skippedSnap, publicSnap] = await Promise.all([ + baseQuery.count().get(), + baseQuery.where('index_status', '==', 'indexed').count().get(), + baseQuery.where('index_status', '==', 'failed').count().get(), + baseQuery.where('index_status', '==', 'skipped').count().get(), + baseQuery.where('is_public', '==', true).count().get(), + ]); + + const total = totalSnap.data().count; + const indexed = indexedSnap.data().count; + const failed = failedSnap.data().count; + const skipped = skippedSnap.data().count; + + stats = { + total, + indexed, + failed, + skipped, + pending: Math.max(0, total - indexed - failed - skipped), + public: publicSnap.data().count, + }; + } + return NextResponse.json({ success: true, resources, - nextCursor + nextCursor, + stats, }); } catch (error) { diff --git a/app/api/webhooks/resend-inbound/route.ts b/app/api/webhooks/resend-inbound/route.ts new file mode 100644 index 0000000..4c5ec02 --- /dev/null +++ b/app/api/webhooks/resend-inbound/route.ts @@ -0,0 +1,155 @@ +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; +import { Resend } from 'resend'; +import { getServerFirestore } from '../../_utils/firebaseAdmin'; +import { indexResource } from '../../_utils/resourceIndexer'; +import { checkResourceLimit } from '../../_utils/subscription'; + +// Resend signs inbound webhooks using the Svix scheme: HMAC-SHA256 over +// "{svix-id}.{svix-timestamp}.{raw body}", base64-encoded, compared against +// one of the space-separated "v1," entries in svix-signature. +const verifySvixSignature = ( + rawBody: string, + svixId: string | null, + svixTimestamp: string | null, + svixSignature: string | null, + secret: string +): boolean => { + if (!svixId || !svixTimestamp || !svixSignature) return false; + + try { + const secretBytes = Buffer.from( + secret.startsWith('whsec_') ? secret.slice('whsec_'.length) : secret, + 'base64' + ); + const signedContent = `${svixId}.${svixTimestamp}.${rawBody}`; + const expectedSignature = crypto.createHmac('sha256', secretBytes).update(signedContent).digest('base64'); + + const providedSignatures = svixSignature + .split(' ') + .map((entry) => entry.split(',')[1]) + .filter(Boolean); + + return providedSignatures.some((sig) => { + try { + return crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(sig)); + } catch { + return false; + } + }); + } catch (err) { + console.error('Error verifying Resend webhook signature:', err); + return false; + } +}; + +export async function POST(request: NextRequest) { + try { + const rawBody = await request.text(); + const webhookSecret = process.env.RESEND_INBOUND_WEBHOOK_SECRET; + + if (webhookSecret) { + const svixId = request.headers.get('svix-id'); + const svixTimestamp = request.headers.get('svix-timestamp'); + const svixSignature = request.headers.get('svix-signature'); + + if (!verifySvixSignature(rawBody, svixId, svixTimestamp, svixSignature, webhookSecret)) { + console.warn('Resend inbound webhook signature verification failed'); + return NextResponse.json({ error: 'Invalid webhook signature' }, { status: 401 }); + } + } + + let payload: any; + try { + payload = JSON.parse(rawBody); + } catch { + return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 }); + } + + // Ignore delivery-status events (email.delivered, email.bounced, etc.) — only act on new mail. + if (payload.type !== 'email.received') { + return NextResponse.json({ success: true, message: 'Event ignored' }); + } + + const emailId = payload.data?.email_id; + if (!emailId) { + return NextResponse.json({ error: 'Missing email_id' }, { status: 400 }); + } + + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey) { + console.error('RESEND_API_KEY not configured — cannot fetch inbound email content'); + return NextResponse.json({ success: true, message: 'Resend not configured' }); + } + + // The webhook payload only carries metadata — fetch the full body separately. + const resend = new Resend(apiKey); + const { data: email, error: fetchError } = await resend.emails.receiving.get(emailId); + + if (fetchError || !email) { + console.error('Failed to fetch inbound email content:', fetchError); + return NextResponse.json({ success: true, message: 'Could not fetch email content' }); + } + + const fromAddress = email.from; + const db = getServerFirestore(); + + // Match by the sender's registered account email — no per-user token needed, + // matches the "just forward it" promise on the landing page. Trade-off: a + // spoofed From header could inject a junk note into someone's private vault + // (no read/exfiltration risk); revisit with rate-limiting if it's abused. + const userSnapshot = await db + .collection('users') + .where('email', '==', fromAddress) + .limit(1) + .get(); + + if (userSnapshot.empty) { + console.warn('Inbound email received but sender did not match any registered account:', fromAddress); + return NextResponse.json({ success: true, message: 'Sender not matched to a user' }); + } + + const uid = userSnapshot.docs[0].id; + + const resourceLimitCheck = await checkResourceLimit(uid); + if (resourceLimitCheck.isLimited) { + console.warn(`Inbound email save skipped for ${uid} — free tier resource limit reached`); + return NextResponse.json({ success: true, message: 'Resource limit reached, save skipped' }); + } + + const bodyText = email.text || (email.html ? email.html.replace(/<[^>]+>/g, ' ').trim() : '') || ''; + const now = new Date(); + const resourceRef = db.collection('resources').doc(); + + await resourceRef.set({ + user_id: uid, + title: email.subject || 'Untitled email', + link: null, + note: null, + tag: 'Note', + is_public: false, + collection_ids: [], + captured_text: bodyText, + email_metadata: { + from: fromAddress, + subject: email.subject || null, + message_id: email.message_id || null, + }, + index_status: 'pending', + index_error: null, + created_at: now, + updated_at: now, + }); + + await indexResource({ resourceId: resourceRef.id, uid }); + + return NextResponse.json({ + success: true, + message: 'Resource created from inbound email', + resourceId: resourceRef.id, + }); + } catch (error) { + console.error('Error processing Resend inbound webhook:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/components/Dashboard.tsx b/app/components/Dashboard.tsx index 9e8b9f2..aae70eb 100644 --- a/app/components/Dashboard.tsx +++ b/app/components/Dashboard.tsx @@ -40,7 +40,7 @@ const statusStyles: Record = { indexed: 'app-chip-success', pending: 'app-chip-warning', failed: 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300', - skipped: '', + skipped: 'border-zinc-200 bg-zinc-100 text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400', } export function Dashboard({ onNavigate }: { onNavigate?: (page: 'dashboard' | 'add' | 'shared' | 'ai' | 'profile') => void }) { @@ -57,6 +57,8 @@ export function Dashboard({ onNavigate }: { onNavigate?: (page: 'dashboard' | 'a const [openMenuResourceId, setOpenMenuResourceId] = useState(null) const [nextCursor, setNextCursor] = useState(null) const [loadingMore, setLoadingMore] = useState(false) + const [selectedStatus, setSelectedStatus] = useState<'all' | 'indexed' | 'pending' | 'failed' | 'skipped'>('all') + const [stats, setStats] = useState<{ total: number; indexed: number; pending: number; failed: number; skipped: number; public: number } | null>(null) const hasInitialFetchRef = useRef(false) useEffect(() => { @@ -88,6 +90,7 @@ export function Dashboard({ onNavigate }: { onNavigate?: (page: 'dashboard' | 'a const data = await response.json() setResources(data.resources || []) setNextCursor(data.nextCursor || null) + if (data.stats) setStats(data.stats) } catch (error) { console.error('Error loading resources:', error) showToast('Failed to load resources. Please try again.') @@ -133,13 +136,14 @@ export function Dashboard({ onNavigate }: { onNavigate?: (page: 'dashboard' | 'a if (selectedTag !== 'all') { filtered = filtered.filter((resource) => resource.tag === selectedTag) } + if (selectedStatus !== 'all') { + filtered = filtered.filter((resource) => (resource.index_status || 'pending') === selectedStatus) + } return filtered - }, [resources, selectedCollectionId, searchQuery, selectedTag]) + }, [resources, selectedCollectionId, searchQuery, selectedTag, selectedStatus]) const tags = useMemo(() => [...new Set(resources.map((resource) => resource.tag).filter(Boolean))], [resources]) const activeCollection = useMemo(() => selectedCollectionId ? collections.find((collection) => collection.id === selectedCollectionId) : null, [selectedCollectionId, collections]) - const indexedCount = resources.filter((resource) => resource.index_status === 'indexed').length - const publicCount = resources.filter((resource) => resource.is_public).length const deleteResource = async (id: string) => { if (!user) return @@ -205,17 +209,21 @@ export function Dashboard({ onNavigate }: { onNavigate?: (page: 'dashboard' | 'a Search, organize, and inspect the resources that power Ask DumpIt.

-
+
-
{resources.length}
+
{stats?.total ?? resources.length}
Resources
-
{indexedCount}
+
{stats?.indexed ?? '—'}
Indexed
-
{publicCount}
+
{(stats?.pending ?? 0) + (stats?.failed ?? 0)}
+
Pending/Failed
+
+
+
{stats?.public ?? '—'}
Public
@@ -241,6 +249,17 @@ export function Dashboard({ onNavigate }: { onNavigate?: (page: 'dashboard' | 'a {tags.map((tag) => )} +
diff --git a/app/components/Layout.tsx b/app/components/Layout.tsx index 8cd9444..c5c3b39 100644 --- a/app/components/Layout.tsx +++ b/app/components/Layout.tsx @@ -1,11 +1,11 @@ 'use client' -import { Bot, LayoutDashboard, LogOut, Plus, Share2, User } from 'lucide-react' +import { Bot, KeyRound, LayoutDashboard, LogOut, Plus, Share2, User } from 'lucide-react' import { ReactNode } from 'react' import { useAuth } from '../contexts/AuthContext' import { ThemeToggle } from './ui/ThemeToggle' -type Page = 'dashboard' | 'add' | 'shared' | 'ai' | 'profile' +type Page = 'dashboard' | 'add' | 'shared' | 'ai' | 'profile' | 'settings' interface LayoutProps { children: ReactNode @@ -19,6 +19,7 @@ const navItems = [ { id: 'add', label: 'Capture', shortLabel: 'Add', icon: Plus }, { id: 'shared', label: 'Shared Dump', shortLabel: 'Shared', icon: Share2 }, { id: 'profile', label: 'Profile', shortLabel: 'Me', icon: User }, + { id: 'settings', label: 'Settings', shortLabel: 'Keys', icon: KeyRound }, ] as const export function Layout({ children, currentPage, onNavigate }: LayoutProps) { @@ -104,7 +105,7 @@ export function Layout({ children, currentPage, onNavigate }: LayoutProps) {