Skip to content

Commit 70a48bb

Browse files
Sai Sridhar Tarraclaude
authored andcommitted
Feat: context-aware replies, auto-rules, templates, signature, calendar, GDPR
- Context-aware replies: embeddings.py now fetches user's sent reply drafts as RAG context, scored by keyword overlap with incoming email — gives AI style reference - Email signature: added email_signature to UserUpdate + UserSettings; ai_processor appends it to every generated draft; editable in Profile & AI settings tab - Auto-labeling rules: localStorage-based rule engine (lib/rules.ts) — IF sender/subject/body matches → set category/priority; applied to email list display; RulesManager settings UI with add/edit/delete/toggle - Email templates: 4 built-in templates + custom templates (localStorage); Templates tab in settings; insert dropdown in ReplyEditor (appends to current draft) - Google Calendar: "Add to Calendar" button in meeting detection banner — opens calendar.google.com with pre-filled title and agenda - GDPR / Data & Privacy: new settings tab; "Download My Data" exports all emails, actions, reply drafts, and profile as JSON; "Delete Account" permanently wipes all user data - Backend: GET /api/settings/export-data and DELETE /api/settings/delete-account endpoints Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent caf42f3 commit 70a48bb

13 files changed

Lines changed: 838 additions & 21 deletions

File tree

backend/ai/embeddings.py

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,72 @@
11
"""
2-
Embedding module — disabled in production to reduce memory usage.
3-
Semantic similarity search is skipped; AI reply generation still works
4-
using Claude directly without past-reply context.
2+
Context-aware reply retrieval using past sent replies as RAG context.
3+
Uses keyword-based search on past sent reply_drafts to find relevant examples.
54
"""
65
import logging
76

87
logger = logging.getLogger(__name__)
98

109

1110
async def generate_embedding(text: str) -> list[float]:
11+
"""Stub — pgvector not enabled. Returns empty list."""
1212
return []
1313

1414

1515
async def store_reply_embedding(user_id: str, email_id: str, reply_text: str) -> None:
16-
logger.debug("Embeddings disabled — skipping store for email_id=%s", email_id)
16+
"""Stub — mark reply draft as stored (no-op for pgvector)."""
17+
logger.debug("Embeddings stub — skipping vector store for email_id=%s", email_id)
1718

1819

1920
async def find_similar_replies(
2021
user_id: str, email_text: str, limit: int = 3
2122
) -> list[str]:
22-
return []
23+
"""
24+
Find relevant past replies by fetching the user's most recently sent
25+
reply drafts. Used as few-shot context for the AI reply generator.
26+
"""
27+
try:
28+
from database import get_supabase
29+
supabase = get_supabase()
30+
31+
# Get recently sent reply drafts (last 10, pick top `limit`)
32+
result = (
33+
supabase.table("reply_drafts")
34+
.select("draft_text, emails(subject, sender)")
35+
.eq("user_id", user_id)
36+
.eq("is_sent", True)
37+
.order("created_at", desc=True)
38+
.limit(10)
39+
.execute()
40+
)
41+
42+
rows = result.data or []
43+
if not rows:
44+
# Fall back to any drafts (not necessarily sent) as style reference
45+
fallback = (
46+
supabase.table("reply_drafts")
47+
.select("draft_text")
48+
.eq("user_id", user_id)
49+
.order("created_at", desc=True)
50+
.limit(limit)
51+
.execute()
52+
)
53+
return [r["draft_text"] for r in (fallback.data or []) if r.get("draft_text")][:limit]
54+
55+
# Score by keyword overlap with email_text
56+
keywords = set(w.lower() for w in email_text.split() if len(w) > 4)
57+
scored: list[tuple[float, str]] = []
58+
for row in rows:
59+
draft = row.get("draft_text", "")
60+
if not draft:
61+
continue
62+
email_meta = row.get("emails") or {}
63+
context = f"{email_meta.get('subject', '')} {email_meta.get('sender', '')}"
64+
overlap = sum(1 for w in context.lower().split() if w in keywords)
65+
scored.append((overlap, draft))
66+
67+
scored.sort(key=lambda x: x[0], reverse=True)
68+
return [text for _, text in scored[:limit]]
69+
70+
except Exception as exc:
71+
logger.warning("find_similar_replies error: %s", exc)
72+
return []

backend/models/user.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class UserUpdate(BaseModel):
2727
priority_threshold: Optional[int] = None
2828
vacation_mode: Optional[bool] = None
2929
vacation_message: Optional[str] = None
30+
email_signature: Optional[str] = None
3031

3132

3233
class ReplyDraftResponse(BaseModel):

backend/routes/settings.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import json
12
import logging
3+
from datetime import datetime, timezone
24
from typing import Annotated
35

46
from fastapi import APIRouter, Depends, HTTPException, status
7+
from fastapi.responses import Response
58

69
from database import get_supabase
710
from middleware.auth import get_current_user
@@ -83,6 +86,59 @@ async def update_settings(
8386
)
8487

8588

89+
@router.get("/export-data")
90+
async def export_user_data(current_user: Annotated[dict, Depends(get_current_user)]):
91+
"""GDPR: Export all user data as a JSON file."""
92+
try:
93+
supabase = get_supabase()
94+
uid = _user_id(current_user)
95+
96+
profile = supabase.table("user_profiles").select("*").eq("id", uid).single().execute()
97+
emails = supabase.table("emails").select(
98+
"id, subject, sender, body, category, priority, ai_summary, received_at, is_read, created_at"
99+
).eq("user_id", uid).order("received_at", desc=True).limit(1000).execute()
100+
actions = supabase.table("actions").select("*").eq("user_id", uid).limit(500).execute()
101+
replies = supabase.table("reply_drafts").select(
102+
"id, email_id, draft_text, is_sent, created_at"
103+
).eq("user_id", uid).limit(500).execute()
104+
105+
export = {
106+
"exported_at": datetime.now(timezone.utc).isoformat(),
107+
"user_id": uid,
108+
"profile": profile.data or {},
109+
"emails": emails.data or [],
110+
"actions": actions.data or [],
111+
"reply_drafts": replies.data or [],
112+
}
113+
114+
filename = f"inboxiq-data-{datetime.now(timezone.utc).strftime('%Y%m%d')}.json"
115+
return Response(
116+
content=json.dumps(export, indent=2, default=str),
117+
media_type="application/json",
118+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
119+
)
120+
except Exception as exc:
121+
logger.error("export_user_data error: %s", exc)
122+
raise HTTPException(status_code=500, detail="Failed to export data.")
123+
124+
125+
@router.delete("/delete-account", status_code=status.HTTP_200_OK)
126+
async def delete_account(current_user: Annotated[dict, Depends(get_current_user)]):
127+
"""GDPR: Delete all user data. Irreversible."""
128+
try:
129+
supabase = get_supabase()
130+
uid = _user_id(current_user)
131+
# Delete in dependency order
132+
supabase.table("reply_drafts").delete().eq("user_id", uid).execute()
133+
supabase.table("actions").delete().eq("user_id", uid).execute()
134+
supabase.table("emails").delete().eq("user_id", uid).execute()
135+
supabase.table("user_profiles").delete().eq("id", uid).execute()
136+
return {"message": "Account and all data deleted successfully."}
137+
except Exception as exc:
138+
logger.error("delete_account error: %s", exc)
139+
raise HTTPException(status_code=500, detail="Failed to delete account.")
140+
141+
86142
@router.get("/profile", response_model=dict)
87143
async def get_profile(current_user: Annotated[dict, Depends(get_current_user)]):
88144
"""

backend/services/ai_processor.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,12 @@ async def process_email(email: dict) -> dict | None:
103103
slack_webhook_url = ""
104104
vacation_mode = False
105105
vacation_message = ""
106+
email_signature = ""
106107

107108
try:
108109
profile_result = (
109110
supabase.table("user_profiles")
110-
.select("company_description, tone_preference, slack_webhook_url, vacation_mode, vacation_message")
111+
.select("company_description, tone_preference, slack_webhook_url, vacation_mode, vacation_message, email_signature")
111112
.eq("id", user_id)
112113
.single()
113114
.execute()
@@ -121,6 +122,7 @@ async def process_email(email: dict) -> dict | None:
121122
slack_webhook_url = profile.get("slack_webhook_url") or ""
122123
vacation_mode = bool(profile.get("vacation_mode", False))
123124
vacation_message = profile.get("vacation_message") or ""
125+
email_signature = profile.get("email_signature") or ""
124126
except Exception as exc:
125127
logger.warning("Could not fetch user profile for user_id=%s: %s", user_id, exc)
126128

@@ -162,7 +164,13 @@ async def process_email(email: dict) -> dict | None:
162164
confidence = 0.3
163165

164166
# ------------------------------------------------------------------
165-
# Step 6a – Prepend vacation auto-reply note if vacation mode is on
167+
# Step 6a – Append email signature if set
168+
# ------------------------------------------------------------------
169+
if email_signature:
170+
draft_text = draft_text.rstrip() + "\n\n" + email_signature.strip()
171+
172+
# ------------------------------------------------------------------
173+
# Step 6c – Prepend vacation auto-reply note if vacation mode is on
166174
# ------------------------------------------------------------------
167175
if vacation_mode:
168176
auto_reply_note = vacation_message.strip() if vacation_message else (
@@ -172,7 +180,7 @@ async def process_email(email: dict) -> dict | None:
172180
draft_text = vacation_prefix + draft_text
173181

174182
# ------------------------------------------------------------------
175-
# Step 6b – Persist reply draft
183+
# Step 6d – Persist reply draft
176184
# ------------------------------------------------------------------
177185
try:
178186
supabase.table("reply_drafts").upsert(

frontend/components/ReplyEditor.tsx

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { useState, useEffect } from 'react';
2-
import { Send, Save, Zap, Loader2, Sparkles } from 'lucide-react';
1+
import { useState, useEffect, useRef } from 'react';
2+
import { Send, Save, Zap, Loader2, Sparkles, FileText, ChevronDown } from 'lucide-react';
33
import clsx from 'clsx';
44
import toast from 'react-hot-toast';
55
import { repliesApi, emailsApi } from '@/lib/api';
6+
import { loadTemplates } from '@/lib/templates';
67
import type { ReplyDraft } from '@/lib/types';
78

89
interface ReplyEditorProps {
@@ -22,6 +23,9 @@ export default function ReplyEditor({ emailId, draft, onSent, className }: Reply
2223
const [isGenerating, setIsGenerating] = useState(false);
2324
const [hasChanges, setHasChanges] = useState(false);
2425
const [confidence, setConfidence] = useState(draft?.confidence_score);
26+
const [templatesOpen, setTemplatesOpen] = useState(false);
27+
const templateMenuRef = useRef<HTMLDivElement>(null);
28+
const templates = loadTemplates();
2529

2630
useEffect(() => {
2731
if (draft?.draft_content) {
@@ -88,6 +92,23 @@ export default function ReplyEditor({ emailId, draft, onSent, className }: Reply
8892
}
8993
};
9094

95+
// Close template dropdown on outside click
96+
useEffect(() => {
97+
const handler = (e: MouseEvent) => {
98+
if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) {
99+
setTemplatesOpen(false);
100+
}
101+
};
102+
document.addEventListener('mousedown', handler);
103+
return () => document.removeEventListener('mousedown', handler);
104+
}, []);
105+
106+
const insertTemplate = (body: string) => {
107+
setContent((prev) => (prev ? prev + '\n\n' + body : body));
108+
setHasChanges(true);
109+
setTemplatesOpen(false);
110+
};
111+
91112
const charPercentage = (content.length / MAX_CHARS) * 100;
92113

93114
return (
@@ -166,7 +187,34 @@ export default function ReplyEditor({ emailId, draft, onSent, className }: Reply
166187
</div>
167188

168189
{/* Actions */}
169-
<div className="flex items-center gap-2 justify-end">
190+
<div className="flex items-center gap-2 justify-end flex-wrap">
191+
{/* Templates dropdown */}
192+
<div className="relative" ref={templateMenuRef}>
193+
<button
194+
onClick={() => setTemplatesOpen((v) => !v)}
195+
className="btn-secondary text-sm gap-1.5"
196+
title="Insert template"
197+
>
198+
<FileText className="h-4 w-4" />
199+
Templates
200+
<ChevronDown className="h-3.5 w-3.5" />
201+
</button>
202+
{templatesOpen && (
203+
<div className="absolute bottom-full mb-1 right-0 z-30 w-56 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-xl overflow-hidden">
204+
{templates.map((t) => (
205+
<button
206+
key={t.id}
207+
onClick={() => insertTemplate(t.body)}
208+
className="w-full text-left px-3 py-2.5 text-sm text-gray-800 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-b border-gray-100 dark:border-gray-700 last:border-0"
209+
>
210+
<p className="font-medium text-xs">{t.name}</p>
211+
<p className="text-xs text-gray-400 dark:text-gray-500 line-clamp-1 mt-0.5">{t.body.split('\n')[0]}</p>
212+
</button>
213+
))}
214+
</div>
215+
)}
216+
</div>
217+
170218
<button
171219
onClick={handleSave}
172220
disabled={isSaving || !hasChanges}

0 commit comments

Comments
 (0)