Skip to content

Commit e180f59

Browse files
Sai Sridhar Tarraclaude
authored andcommitted
Feat: render email HTML in sandboxed iframe + enforce plan limit UI
- Email body renders in sandboxed iframe (like Gmail) — HTML/CSS fully parsed, links clickable and open in new tab, auto-resizes height - Plain text emails: URLs rendered as clickable links - Inbox page shows upgrade banner when free limit (5) is reached - Process button shows clear 'limit reached' toast on 402 response Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a10e63c commit e180f59

5 files changed

Lines changed: 111 additions & 18 deletions

File tree

frontend/components/EmailCard.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,13 @@ export default function EmailCard({ email, className, onDismiss, onProcessed, se
8181
toast.success('Processing with AI…');
8282
onProcessed?.();
8383
} catch (err: unknown) {
84+
const status = (err as { response?: { status?: number } })?.response?.status;
8485
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
85-
toast.error(msg || 'Failed to process email');
86+
if (status === 402) {
87+
toast.error('Monthly limit reached. Upgrade to Pro for unlimited processing.', { duration: 6000, icon: '⚡' });
88+
} else {
89+
toast.error(msg || 'Failed to process email');
90+
}
8691
} finally {
8792
setProcessing(false);
8893
}

frontend/package-lock.json

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,31 +9,33 @@
99
"lint": "next lint"
1010
},
1111
"dependencies": {
12-
"next": "14.1.0",
13-
"react": "^18",
14-
"react-dom": "^18",
15-
"@supabase/supabase-js": "^2.39.3",
12+
"@stripe/stripe-js": "^3.0.6",
1613
"@supabase/auth-helpers-nextjs": "^0.9.0",
1714
"@supabase/auth-helpers-react": "^0.4.2",
1815
"@supabase/auth-ui-react": "^0.4.6",
1916
"@supabase/auth-ui-shared": "^0.1.8",
17+
"@supabase/supabase-js": "^2.39.3",
18+
"@types/dompurify": "^3.0.5",
2019
"axios": "^1.6.7",
20+
"clsx": "^2.1.0",
2121
"date-fns": "^3.3.1",
22-
"react-hot-toast": "^2.4.1",
22+
"dompurify": "^3.3.3",
2323
"lucide-react": "^0.323.0",
24-
"clsx": "^2.1.0",
25-
"@stripe/stripe-js": "^3.0.6",
24+
"next": "14.1.0",
25+
"react": "^18",
26+
"react-dom": "^18",
27+
"react-hot-toast": "^2.4.1",
2628
"swr": "^2.2.5"
2729
},
2830
"devDependencies": {
29-
"typescript": "^5",
3031
"@types/node": "^20",
3132
"@types/react": "^18",
3233
"@types/react-dom": "^18",
3334
"autoprefixer": "^10.0.1",
35+
"eslint": "^8",
36+
"eslint-config-next": "14.1.0",
3437
"postcss": "^8",
3538
"tailwindcss": "^3.3.0",
36-
"eslint": "^8",
37-
"eslint-config-next": "14.1.0"
39+
"typescript": "^5"
3840
}
3941
}

frontend/pages/email/[id].tsx

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Head from 'next/head';
22
import { useRouter } from 'next/router';
3-
import { useEffect, useState } from 'react';
3+
import { useEffect, useRef, useState } from 'react';
44
import { useSessionContext } from '@supabase/auth-helpers-react';
55
import { format } from 'date-fns';
66
import {
@@ -81,6 +81,48 @@ function getFileColor(mimeType: string): string {
8181
return 'text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700';
8282
}
8383

84+
// Renders HTML email inside a sandboxed iframe that auto-resizes — links open in new tab
85+
function EmailBodyFrame({ html }: { html: string }) {
86+
const iframeRef = useRef<HTMLIFrameElement>(null);
87+
const [height, setHeight] = useState(300);
88+
89+
// Inject base target + minimal reset so email CSS doesn't bleed into the page
90+
const doc = `<!DOCTYPE html>
91+
<html>
92+
<head>
93+
<base target="_blank">
94+
<meta charset="utf-8">
95+
<style>
96+
body { margin: 0; padding: 0; font-family: Arial, sans-serif; font-size: 14px; line-height: 1.6; color: #202124; word-break: break-word; }
97+
a { color: #1a73e8; }
98+
img { max-width: 100%; height: auto; }
99+
* { box-sizing: border-box; }
100+
</style>
101+
</head>
102+
<body>${html}</body>
103+
</html>`;
104+
105+
const handleLoad = () => {
106+
const iframe = iframeRef.current;
107+
if (!iframe) return;
108+
try {
109+
const body = iframe.contentDocument?.body;
110+
if (body) setHeight(body.scrollHeight + 24);
111+
} catch {}
112+
};
113+
114+
return (
115+
<iframe
116+
ref={iframeRef}
117+
srcDoc={doc}
118+
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
119+
onLoad={handleLoad}
120+
style={{ width: '100%', height, border: 'none', display: 'block' }}
121+
title="Email body"
122+
/>
123+
);
124+
}
125+
84126
export default function EmailDetailPage() {
85127
const router = useRouter();
86128
const { id } = router.query;
@@ -767,13 +809,17 @@ export default function EmailDetailPage() {
767809
{/* Email body */}
768810
<div className="mt-5 border-t border-gray-100 dark:border-gray-700 pt-5">
769811
{email.body_html ? (
770-
<div
771-
className="prose prose-sm dark:prose-invert max-w-none text-gray-700 dark:text-gray-300 leading-relaxed overflow-x-auto"
772-
dangerouslySetInnerHTML={{ __html: email.body_html }}
773-
/>
812+
<EmailBodyFrame html={email.body_html} />
774813
) : (
775814
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 dark:text-gray-300 leading-relaxed break-words">
776-
{email.body_text || email.snippet}
815+
{(email.body_text || email.snippet || '').replace(
816+
/(https?:\/\/[^\s]+)/g,
817+
'$1'
818+
).split(/(https?:\/\/[^\s]+)/).map((part, i) =>
819+
/^https?:\/\//.test(part)
820+
? <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="text-primary-600 underline break-all">{part}</a>
821+
: part
822+
)}
777823
</pre>
778824
)}
779825
</div>

frontend/pages/email/index.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export default function EmailListPage() {
7474
setPage(1);
7575
}, [router.query.category, router.query.priority_level]);
7676

77-
const { mutate: refreshBilling } = useBillingStatus();
77+
const { data: billing, mutate: refreshBilling } = useBillingStatus();
7878
const { data, isLoading, error, mutate } = useEmails({
7979
category,
8080
priority_level: priorityLevel,
@@ -198,6 +198,20 @@ export default function EmailListPage() {
198198
</Head>
199199
<Layout title="Inbox">
200200
<div className="max-w-4xl mx-auto space-y-4">
201+
{/* Plan limit banner */}
202+
{billing && billing.email_limit !== null && Number(billing.emails_used_this_month) >= Number(billing.email_limit) && (
203+
<div className="rounded-xl border border-amber-200 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-800 p-3 flex items-center justify-between gap-3">
204+
<div className="flex items-center gap-2">
205+
<Zap className="h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
206+
<p className="text-sm text-amber-800 dark:text-amber-300">
207+
<strong>You've used all {billing.email_limit} free AI processes this month.</strong> Upgrade to Pro for unlimited processing.
208+
</p>
209+
</div>
210+
<a href="/billing" className="shrink-0 rounded-lg bg-amber-500 hover:bg-amber-600 text-white text-xs font-semibold px-3 py-1.5 transition-colors">
211+
Upgrade
212+
</a>
213+
</div>
214+
)}
201215
{/* Search and filter bar */}
202216
<div className="card p-3 flex flex-col sm:flex-row gap-3">
203217
<button

0 commit comments

Comments
 (0)