Skip to content

Commit caf42f3

Browse files
Sai Sridhar Tarraclaude
authored andcommitted
Feat: phishing detection, thread summary, bulk summarize UI, CRM pipeline
- Classifier: added is_phishing + phishing_indicators fields to AI analysis - Email detail: phishing warning banner with red alert and indicator list - Email detail: Thread Summary button — AI summary of full thread with key points, next action, sentiment - Backend: GET /api/emails/{id}/thread-summary endpoint - BatchActionBar: Summarize button (up to 10 emails) with callback - BulkSummaryModal: modal to display AI summaries for selected emails - Email index: wires onSummarize callback and shows BulkSummaryModal - CRM: Pipeline tab with full Kanban — New Lead / Contacted / Proposal / Won / Lost - CRM: drag-and-drop + dropdown stage selector on each card, Add Lead modal, localStorage persistence - CRM: tabs UI (Contacts / Pipeline) with won-value tracker Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5355392 commit caf42f3

9 files changed

Lines changed: 660 additions & 9 deletions

File tree

backend/ai/classifier.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
- action_items: array of objects with fields "task" (string) and "deadline" (string or null)
1717
- confidence_score: float from 0.0 to 1.0
1818
- language: ISO 639-1 language code of the email (e.g. 'en', 'es', 'fr', 'de', 'pt', 'hi', 'zh', 'ja')
19+
- is_phishing: boolean — true if the email shows signs of phishing or social engineering
20+
- phishing_indicators: array of strings listing specific suspicious elements found (empty array if none)
1921
2022
Email Subject: {subject}
2123
From: {sender}
@@ -76,6 +78,8 @@ async def classify_email(subject: str, sender: str, body: str, attachments: list
7678
result["action_items"] = result.get("action_items", [])
7779
result["summary"] = result.get("summary", "")
7880
result["language"] = result.get("language", "en")
81+
result["is_phishing"] = bool(result.get("is_phishing", False))
82+
result["phishing_indicators"] = result.get("phishing_indicators", [])
7983

8084
return result
8185

backend/routes/emails.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,92 @@ async def detect_meeting_info(
488488
raise HTTPException(status_code=500, detail="Failed to detect meeting info.")
489489

490490

491+
@router.get("/{email_id}/thread-summary", response_model=dict)
492+
async def get_thread_summary(
493+
email_id: str,
494+
current_user: Annotated[dict, Depends(get_current_user)],
495+
):
496+
"""Generate an AI summary of the entire email thread."""
497+
try:
498+
import json
499+
supabase = get_supabase()
500+
user_id = _current_user_id(current_user)
501+
502+
# Get the email and its thread ID
503+
email_row = supabase.table("emails").select(
504+
"id, subject, sender, gmail_thread_id"
505+
).eq("id", email_id).eq("user_id", user_id).single().execute()
506+
507+
if not email_row.data:
508+
raise HTTPException(status_code=404, detail="Email not found.")
509+
510+
thread_id = email_row.data.get("gmail_thread_id") or email_id
511+
512+
# Fetch all emails in the thread
513+
thread_rows = supabase.table("emails").select(
514+
"id, subject, sender, body, received_at"
515+
).eq("user_id", user_id).eq("gmail_thread_id", thread_id).order(
516+
"received_at", desc=False
517+
).limit(20).execute()
518+
519+
emails_in_thread = thread_rows.data or [email_row.data]
520+
521+
if len(emails_in_thread) <= 1:
522+
return {
523+
"thread_length": 1,
524+
"summary": None,
525+
"key_points": [],
526+
"status": "single_email",
527+
}
528+
529+
# Build thread text for AI
530+
thread_text = ""
531+
for idx, e in enumerate(emails_in_thread, 1):
532+
received = e.get("received_at", "")[:10] if e.get("received_at") else ""
533+
thread_text += f"\n--- Email {idx} ({received}) from {e.get('sender', 'Unknown')} ---\n"
534+
thread_text += f"Subject: {e.get('subject', '')}\n"
535+
thread_text += (e.get("body") or "")[:600]
536+
thread_text += "\n"
537+
538+
from ai.classifier import client as ai_client
539+
540+
prompt = f"""You are summarizing an email thread for a busy professional.
541+
542+
Thread ({len(emails_in_thread)} emails):
543+
{thread_text[:4000]}
544+
545+
Return ONLY a JSON object with:
546+
- summary: string (2-3 sentence overview of the full conversation)
547+
- key_points: array of strings (3-5 key points or decisions from the thread)
548+
- next_action: string or null (what action is needed, if any)
549+
- sentiment: one of "positive", "neutral", "negative", "mixed"
550+
"""
551+
552+
response = await ai_client.messages.create(
553+
model="claude-opus-4-6",
554+
max_tokens=500,
555+
messages=[{"role": "user", "content": prompt}],
556+
)
557+
558+
text = next((b.text for b in response.content if b.type == "text"), None)
559+
raw = (text or "{}").strip()
560+
if raw.startswith("```"):
561+
raw = raw.split("```")[1]
562+
if raw.startswith("json"):
563+
raw = raw[4:]
564+
565+
result = json.loads(raw.strip())
566+
result["thread_length"] = len(emails_in_thread)
567+
result["status"] = "summarized"
568+
return result
569+
570+
except HTTPException:
571+
raise
572+
except Exception as exc:
573+
logger.error("thread_summary error: %s", exc)
574+
raise HTTPException(status_code=500, detail="Failed to generate thread summary.")
575+
576+
491577
@router.get("/{email_id}")
492578
async def get_email(
493579
email_id: str,

frontend/components/BatchActionBar.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState } from 'react';
2-
import { CheckCheck, Eye, Trash2, X, Loader2 } from 'lucide-react';
2+
import { CheckCheck, Eye, Trash2, X, Loader2, FileText } from 'lucide-react';
33
import clsx from 'clsx';
44
import toast from 'react-hot-toast';
55
import { emailsApi } from '@/lib/api';
@@ -8,9 +8,10 @@ interface BatchActionBarProps {
88
selectedIds: Set<string>;
99
onCancel: () => void;
1010
onComplete: () => void;
11+
onSummarize?: (summaries: Array<{ id: string; summary: string }>) => void;
1112
}
1213

13-
export default function BatchActionBar({ selectedIds, onCancel, onComplete }: BatchActionBarProps) {
14+
export default function BatchActionBar({ selectedIds, onCancel, onComplete, onSummarize }: BatchActionBarProps) {
1415
const [loading, setLoading] = useState<string | null>(null);
1516
const ids = Array.from(selectedIds);
1617
const count = ids.length;
@@ -41,6 +42,26 @@ export default function BatchActionBar({ selectedIds, onCancel, onComplete }: Ba
4142
}
4243
};
4344

45+
const handleSummarize = async () => {
46+
if (ids.length > 10) {
47+
toast.error('Summarize supports up to 10 emails at a time');
48+
return;
49+
}
50+
setLoading('summarize');
51+
try {
52+
const results = await emailsApi.bulkSummarize(ids);
53+
if (onSummarize) {
54+
onSummarize(results);
55+
} else {
56+
toast.success(`Generated ${results.length} summaries`);
57+
}
58+
} catch {
59+
toast.error('Failed to summarize emails');
60+
} finally {
61+
setLoading(null);
62+
}
63+
};
64+
4465
const handleBulkProcess = async () => {
4566
setLoading('process');
4667
try {
@@ -73,6 +94,17 @@ export default function BatchActionBar({ selectedIds, onCancel, onComplete }: Ba
7394
Mark read
7495
</button>
7596

97+
{count <= 10 && (
98+
<button
99+
onClick={handleSummarize}
100+
disabled={!!loading}
101+
className="inline-flex items-center gap-1.5 rounded-lg bg-white/15 hover:bg-white/25 px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50"
102+
>
103+
{loading === 'summarize' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
104+
Summarize
105+
</button>
106+
)}
107+
76108
<button
77109
onClick={handleBulkProcess}
78110
disabled={!!loading}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { X, FileText } from 'lucide-react';
2+
3+
interface SummaryItem {
4+
id: string;
5+
summary: string;
6+
}
7+
8+
interface BulkSummaryModalProps {
9+
summaries: SummaryItem[];
10+
onClose: () => void;
11+
}
12+
13+
export default function BulkSummaryModal({ summaries, onClose }: BulkSummaryModalProps) {
14+
return (
15+
<>
16+
<div
17+
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
18+
onClick={onClose}
19+
aria-hidden="true"
20+
/>
21+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
22+
<div className="w-full max-w-xl rounded-2xl bg-white dark:bg-gray-900 shadow-2xl flex flex-col max-h-[80vh]">
23+
{/* Header */}
24+
<div className="flex items-center justify-between border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex-shrink-0">
25+
<div className="flex items-center gap-2">
26+
<FileText className="h-4 w-4 text-primary-600 dark:text-primary-400" />
27+
<h2 className="font-semibold text-gray-900 dark:text-gray-100 text-sm">
28+
AI Summaries ({summaries.length})
29+
</h2>
30+
</div>
31+
<button
32+
onClick={onClose}
33+
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
34+
>
35+
<X className="h-4 w-4" />
36+
</button>
37+
</div>
38+
39+
{/* List */}
40+
<div className="overflow-y-auto flex-1 p-5 space-y-3">
41+
{summaries.map((item, idx) => (
42+
<div
43+
key={item.id}
44+
className="rounded-xl border border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-4"
45+
>
46+
<p className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1.5">
47+
Email {idx + 1}
48+
</p>
49+
<p className="text-sm text-gray-800 dark:text-gray-200 leading-relaxed">
50+
{item.summary}
51+
</p>
52+
</div>
53+
))}
54+
</div>
55+
56+
<div className="border-t border-gray-200 dark:border-gray-700 px-5 py-3 flex-shrink-0">
57+
<button
58+
onClick={onClose}
59+
className="w-full rounded-xl bg-primary-600 hover:bg-primary-700 text-white py-2 text-sm font-medium transition-colors"
60+
>
61+
Done
62+
</button>
63+
</div>
64+
</div>
65+
</div>
66+
</>
67+
);
68+
}

frontend/lib/api.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,18 @@ export const emailsApi = {
223223
const { data } = await api.get(`/api/emails/${id}/meeting-info`);
224224
return data;
225225
},
226+
227+
getThreadSummary: async (id: string): Promise<{
228+
thread_length: number;
229+
summary: string | null;
230+
key_points: string[];
231+
next_action: string | null;
232+
sentiment: string;
233+
status: string;
234+
}> => {
235+
const { data } = await api.get(`/api/emails/${id}/thread-summary`);
236+
return data;
237+
},
226238
};
227239

228240
// ─── Actions Endpoints ────────────────────────────────────────────────────────

frontend/lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface AIAnalysis {
2121
key_topics: string[];
2222
action_required: boolean;
2323
processed_at: string;
24+
is_phishing?: boolean;
25+
phishing_indicators?: string[];
2426
}
2527

2628
export interface Email {

0 commit comments

Comments
 (0)