Skip to content

Commit a10e63c

Browse files
Sai Sridhar Tarraclaude
authored andcommitted
Feat: manual AI processing per email — no auto-process on sync
- Sync endpoint now only fetches/stores emails, no AI processing - Added "Process with AI" button on each EmailCard (unprocessed only) - After processing, refreshes email list + billing usage count - Background poll still auto-processes (for future scheduled use) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a199d86 commit a10e63c

5 files changed

Lines changed: 80 additions & 10 deletions

File tree

backend/routes/emails.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from services.ai_processor import process_email
1616
from services.gmail_service import send_gmail_reply, get_email_attachments, get_attachment_data
1717
from services.razorpay_service import PLAN_LIMITS
18-
from workers.email_listener import _process_user_emails
18+
from workers.email_listener import _sync_user_emails
1919

2020
logger = logging.getLogger(__name__)
2121
router = APIRouter(prefix="/emails", tags=["emails"])
@@ -158,7 +158,7 @@ async def sync_emails(
158158
current_user: Annotated[dict, Depends(get_current_user)],
159159
):
160160
"""Manually trigger a Gmail sync for the current user."""
161-
background_tasks.add_task(_process_user_emails, _current_user_id(current_user))
161+
background_tasks.add_task(_sync_user_emails, _current_user_id(current_user))
162162
return {"message": "Gmail sync started."}
163163

164164

backend/workers/email_listener.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,53 @@ async def _get_gmail_connected_users() -> list[str]:
3636
return []
3737

3838

39+
async def _sync_user_emails(user_id: str) -> None:
40+
"""
41+
Fetch unread Gmail messages for one user and store new ones in Supabase.
42+
Does NOT run AI processing — users trigger that manually per email.
43+
"""
44+
try:
45+
raw_emails = await fetch_new_emails(user_id=user_id)
46+
if not raw_emails:
47+
return
48+
49+
logger.info("Fetched %d emails for user_id=%s", len(raw_emails), user_id)
50+
51+
supabase = get_supabase()
52+
53+
for raw in raw_emails:
54+
gmail_message_id = raw.get("gmail_message_id")
55+
56+
# Skip duplicates already stored
57+
if gmail_message_id:
58+
existing = (
59+
supabase.table("emails")
60+
.select("id")
61+
.eq("gmail_message_id", gmail_message_id)
62+
.eq("user_id", user_id)
63+
.execute()
64+
)
65+
if existing.data:
66+
continue
67+
68+
email_create = EmailCreate(
69+
user_id=user_id,
70+
subject=raw.get("subject", "(No Subject)"),
71+
sender=raw.get("sender", ""),
72+
body=raw.get("body", ""),
73+
received_at=raw.get("received_at"),
74+
gmail_message_id=gmail_message_id,
75+
thread_id=raw.get("thread_id"),
76+
)
77+
78+
await create_email(email_create)
79+
80+
3981
async def _process_user_emails(user_id: str) -> None:
4082
"""
41-
Fetch unread Gmail messages for one user, store new ones in Supabase,
42-
then queue AI processing.
83+
Fetch and store emails, then auto-run AI processing.
84+
Used by the background poll (poll_all_users) — not triggered by manual sync.
4385
"""
44-
# Import here to avoid circular dependency at module load time
4586
from services.ai_processor import process_email as ai_process
4687

4788
try:
@@ -56,7 +97,6 @@ async def _process_user_emails(user_id: str) -> None:
5697
for raw in raw_emails:
5798
gmail_message_id = raw.get("gmail_message_id")
5899

59-
# Skip duplicates already stored
60100
if gmail_message_id:
61101
existing = (
62102
supabase.table("emails")
@@ -80,7 +120,6 @@ async def _process_user_emails(user_id: str) -> None:
80120

81121
stored = await create_email(email_create)
82122
if stored:
83-
# Run AI pipeline asynchronously — don't block the listener
84123
asyncio.create_task(ai_process(stored))
85124

86125
except Exception as exc:

frontend/components/EmailCard.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState } from 'react';
22
import { useRouter } from 'next/router';
33
import { formatDistanceToNow } from 'date-fns';
4-
import { ChevronRight, CheckSquare, Paperclip, X, Star, AlarmClock } from 'lucide-react';
4+
import { ChevronRight, CheckSquare, Paperclip, X, Star, AlarmClock, Zap, Loader2 } from 'lucide-react';
55
import clsx from 'clsx';
66
import toast from 'react-hot-toast';
77
import type { Email } from '@/lib/types';
@@ -14,6 +14,7 @@ interface EmailCardProps {
1414
email: Email;
1515
className?: string;
1616
onDismiss?: (id: string) => void;
17+
onProcessed?: () => void;
1718
selected?: boolean;
1819
onToggleSelect?: () => void;
1920
}
@@ -35,9 +36,10 @@ function getGradient(name: string) {
3536
return AVATAR_GRADIENTS[code];
3637
}
3738

38-
export default function EmailCard({ email, className, onDismiss, selected, onToggleSelect }: EmailCardProps) {
39+
export default function EmailCard({ email, className, onDismiss, onProcessed, selected, onToggleSelect }: EmailCardProps) {
3940
const router = useRouter();
4041
const [dismissing, setDismissing] = useState(false);
42+
const [processing, setProcessing] = useState(false);
4143
const [starred, setStarred] = useState(email.is_starred);
4244
const [snoozeOpen, setSnoozeOpen] = useState(false);
4345

@@ -71,6 +73,21 @@ export default function EmailCard({ email, className, onDismiss, selected, onTog
7173
}
7274
};
7375

76+
const handleProcess = async (e: React.MouseEvent) => {
77+
e.stopPropagation();
78+
setProcessing(true);
79+
try {
80+
await emailsApi.processEmail(email.id);
81+
toast.success('Processing with AI…');
82+
onProcessed?.();
83+
} catch (err: unknown) {
84+
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
85+
toast.error(msg || 'Failed to process email');
86+
} finally {
87+
setProcessing(false);
88+
}
89+
};
90+
7491
const handleSnoozeClick = (e: React.MouseEvent) => {
7592
e.stopPropagation();
7693
setSnoozeOpen(true);
@@ -181,6 +198,17 @@ export default function EmailCard({ email, className, onDismiss, selected, onTog
181198

182199
{/* Badges row */}
183200
<div className="mt-2 flex items-center gap-1.5 sm:gap-2 flex-wrap">
201+
{/* Process with AI button — only shown for unprocessed emails */}
202+
{!email.processed && !email.ai_analysis && (
203+
<button
204+
onClick={handleProcess}
205+
disabled={processing}
206+
className="inline-flex items-center gap-1 rounded-full bg-primary-50 dark:bg-primary-900/30 px-2 py-0.5 text-xs font-medium text-primary-700 dark:text-primary-300 border border-primary-200 dark:border-primary-700 hover:bg-primary-100 dark:hover:bg-primary-900/50 transition-colors"
207+
>
208+
{processing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Zap className="h-3 w-3" />}
209+
{processing ? 'Processing…' : 'Process with AI'}
210+
</button>
211+
)}
184212
{analysis?.category && (
185213
<CategoryBadge category={analysis.category} size="sm" />
186214
)}

frontend/lib/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export interface Email {
4242
is_starred: boolean;
4343
snooze_until?: string | null;
4444
labels: string[];
45+
processed?: boolean;
4546
ai_analysis?: AIAnalysis;
4647
action_count?: number;
4748
created_at: string;

frontend/pages/email/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { ErrorCard } from '@/components/ErrorBoundary';
1616
import CategoryBadge from '@/components/CategoryBadge';
1717
import BatchActionBar from '@/components/BatchActionBar';
1818
import BulkSummaryModal from '@/components/BulkSummaryModal';
19-
import { useEmails } from '@/lib/hooks';
19+
import { useEmails, useBillingStatus } from '@/lib/hooks';
2020
import { emailsApi } from '@/lib/api';
2121
import { loadRules, applyRules } from '@/lib/rules';
2222
import type { EmailCategory, PriorityLevel, Email } from '@/lib/types';
@@ -74,6 +74,7 @@ export default function EmailListPage() {
7474
setPage(1);
7575
}, [router.query.category, router.query.priority_level]);
7676

77+
const { mutate: refreshBilling } = useBillingStatus();
7778
const { data, isLoading, error, mutate } = useEmails({
7879
category,
7980
priority_level: priorityLevel,
@@ -376,6 +377,7 @@ export default function EmailListPage() {
376377
<EmailCard
377378
email={email}
378379
onDismiss={() => mutate()}
380+
onProcessed={() => { mutate(); refreshBilling(); }}
379381
selected={selectionMode ? selectedIds.has(email.id) : undefined}
380382
onToggleSelect={selectionMode ? () => toggleSelect(email.id) : undefined}
381383
/>

0 commit comments

Comments
 (0)