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) {