Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 16 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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
Expand All @@ -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 |

Expand Down
31 changes: 30 additions & 1 deletion app/api/resources/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
155 changes: 155 additions & 0 deletions app/api/webhooks/resend-inbound/route.ts
Original file line number Diff line number Diff line change
@@ -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,<sig>" 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 });
}
}
35 changes: 27 additions & 8 deletions app/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const statusStyles: Record<string, string> = {
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 }) {
Expand All @@ -57,6 +57,8 @@ export function Dashboard({ onNavigate }: { onNavigate?: (page: 'dashboard' | 'a
const [openMenuResourceId, setOpenMenuResourceId] = useState<string | null>(null)
const [nextCursor, setNextCursor] = useState<string | null>(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(() => {
Expand Down Expand Up @@ -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.')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -205,17 +209,21 @@ export function Dashboard({ onNavigate }: { onNavigate?: (page: 'dashboard' | 'a
Search, organize, and inspect the resources that power Ask DumpIt.
</p>
</div>
<div className="grid grid-cols-3 gap-2 sm:min-w-[360px]">
<div className="grid grid-cols-4 gap-2 sm:min-w-[440px]">
<div className="app-muted-panel p-3">
<div className="text-xl font-bold text-zinc-950 dark:text-white">{resources.length}</div>
<div className="text-xl font-bold text-zinc-950 dark:text-white">{stats?.total ?? resources.length}</div>
<div className="text-xs text-zinc-500">Resources</div>
</div>
<div className="app-muted-panel p-3">
<div className="text-xl font-bold text-zinc-950 dark:text-white">{indexedCount}</div>
<div className="text-xl font-bold text-zinc-950 dark:text-white">{stats?.indexed ?? '—'}</div>
<div className="text-xs text-zinc-500">Indexed</div>
</div>
<div className="app-muted-panel p-3">
<div className="text-xl font-bold text-zinc-950 dark:text-white">{publicCount}</div>
<div className="text-xl font-bold text-zinc-950 dark:text-white">{(stats?.pending ?? 0) + (stats?.failed ?? 0)}</div>
<div className="text-xs text-zinc-500">Pending/Failed</div>
</div>
<div className="app-muted-panel p-3">
<div className="text-xl font-bold text-zinc-950 dark:text-white">{stats?.public ?? '—'}</div>
<div className="text-xs text-zinc-500">Public</div>
</div>
</div>
Expand All @@ -241,6 +249,17 @@ export function Dashboard({ onNavigate }: { onNavigate?: (page: 'dashboard' | 'a
<option value="all">All tags</option>
{tags.map((tag) => <option key={tag} value={tag}>{tag}</option>)}
</select>
<select
value={selectedStatus}
onChange={(event) => setSelectedStatus(event.target.value as typeof selectedStatus)}
className="app-input md:w-44"
>
<option value="all">All statuses</option>
<option value="indexed">Indexed</option>
<option value="pending">Pending</option>
<option value="failed">Failed</option>
<option value="skipped">Skipped</option>
</select>
</div>
</div>

Expand Down
7 changes: 4 additions & 3 deletions app/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -104,7 +105,7 @@ export function Layout({ children, currentPage, onNavigate }: LayoutProps) {
</main>

<nav className="fixed inset-x-0 bottom-0 z-40 border-t border-zinc-200 bg-white/95 px-2 py-2 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/95 lg:hidden">
<div className="grid grid-cols-5 gap-1">
<div className="grid grid-cols-6 gap-1">
{navItems.map((item) => {
const Icon = item.icon
const active = currentPage === item.id
Expand Down
Loading
Loading