Compare commits

..

1 Commits

Author SHA1 Message Date
cesnimda c6a8302997 chore: strip UTF-8 BOM from EF-generated migration files
CI / backend (pull_request) Successful in 57s
CI / frontend (pull_request) Successful in 12s
CI / format (pull_request) Successful in 51s
CI / db-tests (pull_request) Successful in 55s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 56s
Security / sast (pull_request) Successful in 40s
The .editorconfig charset=utf-8 rule (added with the CI format gate) flags the BOM
that 'dotnet ef migrations' writes into generated files, failing the format check on
develop itself and thus on every PR. De-BOM all tracked source files so the gate passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:27:02 +02:00
27 changed files with 195 additions and 1813 deletions
-3
View File
@@ -70,6 +70,3 @@ vendor/
coverage/ coverage/
.cache/ .cache/
tmp/ tmp/
# agent worktrees (never commit)
.claude/worktrees/
-12
View File
@@ -53,16 +53,6 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
# One-shot: ensure the DataProtection 'keys' volume is owned by the API's non-root
# 'app' user (uid 1654). A volume created by an older root-running image is root-owned,
# which makes the app fail to read its key ring and 500s on login. Runs as root, chowns,
# exits; the api waits for it. Idempotent and cheap.
init-keys:
image: busybox
command: ["sh", "-c", "chown -R 1654:1654 /keys"]
volumes:
- keys:/keys
api: api:
build: build:
context: . context: .
@@ -89,8 +79,6 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
init-keys:
condition: service_completed_successfully
# V-08: bind to loopback so the API is not directly reachable from the network # V-08: bind to loopback so the API is not directly reachable from the network
# (only via the frontend/nginx proxy over the internal compose network). This # (only via the frontend/nginx proxy over the internal compose network). This
# prevents external clients from bypassing the proxy to spoof X-Forwarded-* headers. # prevents external clients from bypassing the proxy to spoof X-Forwarded-* headers.
+3 -11
View File
@@ -38,6 +38,7 @@ export const AnalyticsApi = {
health: () => api.get('/analytics/health').then((r) => r.data), health: () => api.get('/analytics/health').then((r) => r.data),
topSenders: (take = 20) => api.get(`/analytics/top-senders?take=${take}`).then((r) => r.data), topSenders: (take = 20) => api.get(`/analytics/top-senders?take=${take}`).then((r) => r.data),
volume: (days = 90) => api.get(`/analytics/volume?days=${days}`).then((r) => r.data), volume: (days = 90) => api.get(`/analytics/volume?days=${days}`).then((r) => r.data),
heatmap: () => api.get('/analytics/heatmap').then((r) => r.data),
categoryHeatmap: () => api.get('/analytics/category-heatmap').then((r) => r.data), categoryHeatmap: () => api.get('/analytics/category-heatmap').then((r) => r.data),
attachments: () => api.get('/analytics/attachments').then((r) => r.data), attachments: () => api.get('/analytics/attachments').then((r) => r.data),
sidebarCounts: () => api.get('/analytics/sidebar-counts').then((r) => r.data), sidebarCounts: () => api.get('/analytics/sidebar-counts').then((r) => r.data),
@@ -93,22 +94,13 @@ function folderToRequest(slug, page, pageSize) {
case 'starred': return { ...base, isStarred: true }; case 'starred': return { ...base, isStarred: true };
case 'sent': return { ...base, gmailLabel: 'SENT' }; case 'sent': return { ...base, gmailLabel: 'SENT' };
case 'drafts': return { ...base, gmailLabel: 'DRAFT' }; case 'drafts': return { ...base, gmailLabel: 'DRAFT' };
// Archive = filed away: not in inbox, not trashed, and NOT Sent/Spam/Draft/Chat. case 'archive': return { ...base, isInInbox: false, isTrashed: false };
// Excluding those labels stops sent mail leaking into Archive.
case 'archive': return { ...base, isInInbox: false, isTrashed: false, excludeGmailLabels: ['SENT', 'DRAFT', 'SPAM', 'TRASH', 'CHAT'] };
case 'spam': return { ...base, gmailLabel: 'SPAM' }; case 'spam': return { ...base, gmailLabel: 'SPAM' };
case 'trash': return { ...base, isTrashed: true }; case 'trash': return { ...base, isTrashed: true };
// Pinned = Gmail's "Important" marker (a real per-message flag), not "everything".
case 'pinned': return { ...base, isImportant: true };
// Read Later = only emails the user explicitly flagged (local marker).
case 'readlater': return { ...base, isReadLater: true };
// Unlabelled = emails not filed under any user-created label.
case 'unlabelled': return { ...base, hasUserLabels: false };
// ── Special filters ── // ── Special filters ──
case 'large': return { ...base, minSizeBytes: 5_000_000 }; case 'large': return { ...base, minSizeBytes: 5_000_000 };
// Old Mail = strictly older than 6 months.
case 'old': { case 'old': {
const d = new Date(); d.setMonth(d.getMonth() - 6); const d = new Date(); d.setFullYear(d.getFullYear() - 1);
return { ...base, to: d.toISOString().slice(0, 10) }; return { ...base, to: d.toISOString().slice(0, 10) };
} }
// ── Smart folders ── // ── Smart folders ──
+20 -49
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { MailOpen, Mail, Star, Archive, Trash2, X } from 'lucide-react'; import { MailOpen, Mail, Star, Archive, Trash2 } from 'lucide-react';
import { BulkApi } from '../api/client.js'; import { BulkApi } from '../api/client.js';
import { import {
Button, useToast, Button, useToast,
@@ -49,54 +49,25 @@ export default function BulkToolbar({ selectedIds, onDone, onClear }) {
}; };
return ( return (
<div <div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 shadow-sm">
role="toolbar" <span className="text-sm font-medium">{count} selected</span>
aria-label={`${count} email${count === 1 ? '' : 's'} selected`} <div className="flex-1" />
className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border border-border bg-card px-3 py-2 shadow-sm" <Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markRead, 'read', 'Marked read')}>
> <MailOpen /> Read
{/* Selection count — primary emphasis so it reads first. */} </Button>
<div className="flex items-center gap-2"> <Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markUnread, 'unread', 'Marked unread')}>
<span <Mail /> Unread
aria-hidden="true" </Button>
className="inline-flex h-7 min-w-7 items-center justify-center rounded-full bg-primary px-2 text-sm font-semibold tabular-nums text-primary-foreground" <Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.star, 'star', 'Starred')}>
> <Star /> Star
{count} </Button>
</span> <Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.archive, 'archive', 'Archived')}>
<span className="text-sm font-medium text-foreground"> <Archive /> Archive
selected </Button>
</span> <Button variant="danger-outline" size="sm" disabled={busy} onClick={() => setConfirmTrash(true)}>
{/* Obvious clear-selection affordance, kept next to the count. */} <Trash2 /> Trash
<Button </Button>
variant="ghost" <Button variant="ghost" size="sm" disabled={busy} onClick={onClear}>Cancel</Button>
size="icon-sm"
disabled={busy}
onClick={onClear}
aria-label="Clear selection"
title="Clear selection"
>
<X />
</Button>
</div>
<div className="flex-1 basis-full sm:basis-0" />
<div className="flex flex-wrap items-center gap-2">
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markRead, 'read', 'Marked read')}>
<MailOpen /> Read
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markUnread, 'unread', 'Marked unread')}>
<Mail /> Unread
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.star, 'star', 'Starred')}>
<Star /> Star
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.archive, 'archive', 'Archived')}>
<Archive /> Archive
</Button>
<Button variant="danger-outline" size="sm" disabled={busy} onClick={() => setConfirmTrash(true)}>
<Trash2 /> Trash
</Button>
</div>
<Dialog open={confirmTrash} onOpenChange={(o) => !busy && setConfirmTrash(o)}> <Dialog open={confirmTrash} onOpenChange={(o) => !busy && setConfirmTrash(o)}>
<DialogContent> <DialogContent>
-115
View File
@@ -1,115 +0,0 @@
import { useEffect, useState } from 'react';
import { EmailApi } from '../api/client.js';
// ── helpers ──────────────────────────────────────────────────────────────────
const fmtSize = (b) => {
if (!b) return '';
if (b < 1024) return `${b} B`;
if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
return `${(b / 1048576).toFixed(1)} MB`;
};
/**
* Shared reading-pane / full single-email view.
*
* Accepts either an `email` summary object (as emitted by the list rows) or a
* bare `emailId`. Fetches the full detail via EmailApi.get(id) and the AI
* summary lazily via EmailApi.summary(id). Renders subject, metadata, an AI
* summary button, the body, and per-email actions including "Open in Gmail".
*
* `onClose` collapses the pane.
*/
export default function EmailDetail({ email: summary, emailId, onClose }) {
const id = summary?.id ?? emailId;
const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(true);
const [aiSummary, setAiSummary] = useState(null);
const [aiLoading, setAiLoading] = useState(false);
useEffect(() => {
if (id == null) return;
let cancelled = false;
setDetail(null);
setLoading(true);
setAiSummary(null);
setAiLoading(false);
EmailApi.get(id)
.then((d) => { if (!cancelled) setDetail(d); })
.catch(() => { if (!cancelled) setDetail(null); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [id]);
const email = detail ?? summary ?? {};
const fetchAiSummary = () => {
if (id == null) return;
setAiLoading(true);
EmailApi.summary(id)
.then((r) => setAiSummary(r.summary))
.catch(() => setAiSummary(null))
.finally(() => setAiLoading(false));
};
const openInGmail = () => window.open(
`https://mail.google.com/mail/u/0/#all/${email.gmailMessageId}`,
'_blank', 'noopener,noreferrer'
);
return (
<div className="sv-detail">
<div className="sv-detail-topbar">
<button className="sv-close" onClick={onClose} aria-label="Close reading pane" title="Close">
</button>
</div>
<div className="sv-detail-header">
<div className="sv-detail-subject">{email.subject || '(no subject)'}</div>
<div className="sv-detail-meta">
<span>{email.senderDisplayName || email.senderAddress}</span>
{email.sentAtUtc && (
<>
<span className="sv-detail-sep">·</span>
<span>{new Date(email.sentAtUtc).toLocaleString()}</span>
</>
)}
{email.sizeEstimateBytes > 0 && (
<>
<span className="sv-detail-sep">·</span>
<span>{fmtSize(email.sizeEstimateBytes)}</span>
</>
)}
</div>
</div>
{!loading && (
<div className="sv-ai-summary">
{aiSummary != null ? (
<div className="sv-ai-summary-text"> {aiSummary}</div>
) : (
<button className="btn-sm" onClick={fetchAiSummary} disabled={aiLoading}>
{aiLoading ? 'Summarising…' : '✨ AI summary'}
</button>
)}
</div>
)}
{loading && <div className="sv-body-loading muted">Loading message</div>}
{!loading && detail?.bodyText && (
<div className="sv-body">{detail.bodyText}</div>
)}
{!loading && !detail?.bodyText && email.snippet && (
<div className="sv-snippet">{email.snippet}</div>
)}
<div className="sv-detail-actions">
<button className="btn-sm" onClick={openInGmail}>Open in Gmail </button>
</div>
</div>
);
}
+6 -16
View File
@@ -1,6 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { EmailApi } from '../api/client.js'; import { EmailApi } from '../api/client.js';
import { Checkbox } from './ui/checkbox.jsx';
const fmtDate = (iso) => { const fmtDate = (iso) => {
const d = new Date(iso); const d = new Date(iso);
@@ -36,7 +35,7 @@ function renderHighlight(s) {
return out; return out;
} }
export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect, focused, onOpen }) { export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect, focused }) {
const [email, setEmail] = useState(initial); const [email, setEmail] = useState(initial);
const [acting, setActing] = useState(false); const [acting, setActing] = useState(false);
@@ -89,16 +88,12 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS
return ( return (
<tr <tr
className={`email-row${email.isUnread ? ' email-row--unread' : ''}${acting ? ' email-row--acting' : ''}${focused ? ' email-row--focused' : ''}`} className={`email-row${email.isUnread ? ' email-row--unread' : ''}${acting ? ' email-row--acting' : ''}${focused ? ' email-row--focused' : ''}`}
onClick={() => onOpen ? onOpen(email) : openInGmail()} onClick={openInGmail}
title={onOpen ? 'Open' : 'Open in Gmail'} title="Open in Gmail"
> >
{onToggleSelect && ( {onToggleSelect && (
<td className="el-select" onClick={(e) => e.stopPropagation()}> <td className="el-select" onClick={(e) => e.stopPropagation()}>
<Checkbox <input type="checkbox" checked={!!selected} onChange={() => onToggleSelect(email.id)} />
checked={!!selected}
onCheckedChange={() => onToggleSelect(email.id)}
aria-label={selected ? 'Deselect email' : 'Select email'}
/>
</td> </td>
)} )}
<td className="el-unread">{email.isUnread && <span className="unread-dot" />}</td> <td className="el-unread">{email.isUnread && <span className="unread-dot" />}</td>
@@ -108,8 +103,8 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS
<td className="el-subject"> <td className="el-subject">
<span className="el-subj-text">{email.subject || '(no subject)'}</span> <span className="el-subj-text">{email.subject || '(no subject)'}</span>
{email.matchHighlight {email.matchHighlight
? <span className="el-snippet">{renderHighlight(email.matchHighlight)}</span> ? <span className="el-snippet"> {renderHighlight(email.matchHighlight)}</span>
: email.snippet && <span className="el-snippet">{email.snippet}</span>} : email.snippet && <span className="el-snippet"> {email.snippet}</span>}
</td> </td>
<td className="el-meta"> <td className="el-meta">
{email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>} {email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>}
@@ -139,11 +134,6 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS
disabled={email._unsubDone} disabled={email._unsubDone}
>{email._unsubDone ? '✓' : '✉✕'}</button> >{email._unsubDone ? '✓' : '✉✕'}</button>
)} )}
<button
className="action-btn"
title="Open in Gmail"
onClick={(e) => { e.stopPropagation(); openInGmail(); }}
></button>
<button <button
className="action-btn action-btn--danger" className="action-btn action-btn--danger"
title="Move to trash" title="Move to trash"
-43
View File
@@ -1,43 +0,0 @@
import { forwardRef } from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from '../../lib/utils.js';
/**
* Accessible, design-token styled checkbox.
* Wraps Radix Checkbox so it is keyboard-accessible with a visible focus ring.
* Accepts `checked`, `onCheckedChange` (Radix) and, for convenience, `onChange`
* (called with a synthetic-ish `{ target: { checked } }`) so it can drop into
* places that previously used a bare <input type="checkbox">.
*/
const Checkbox = forwardRef(function Checkbox(
{ className, onCheckedChange, onChange, ...props },
ref
) {
const handleCheckedChange = (checked) => {
onCheckedChange?.(checked);
onChange?.({ target: { checked } });
};
return (
<CheckboxPrimitive.Root
ref={ref}
onCheckedChange={handleCheckedChange}
className={cn(
'peer inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-[4px] border border-border bg-card transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background',
'hover:border-primary/60',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
<Check className="h-3 w-3" strokeWidth={3} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
});
export { Checkbox };
-1
View File
@@ -2,7 +2,6 @@
export { Button, buttonVariants } from './button.jsx'; export { Button, buttonVariants } from './button.jsx';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card.jsx'; export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card.jsx';
export { Badge, badgeVariants } from './badge.jsx'; export { Badge, badgeVariants } from './badge.jsx';
export { Checkbox } from './checkbox.jsx';
export { Input, Textarea } from './input.jsx'; export { Input, Textarea } from './input.jsx';
export { Switch } from './switch.jsx'; export { Switch } from './switch.jsx';
export { Separator } from './separator.jsx'; export { Separator } from './separator.jsx';
+56 -62
View File
@@ -1,16 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Line, Doughnut } from 'react-chartjs-2'; import { Bar, Line, Doughnut } from 'react-chartjs-2';
import { Inbox } from 'lucide-react';
import { AnalyticsApi } from '../api/client.js'; import { AnalyticsApi } from '../api/client.js';
import { Skeleton } from './ui/skeleton.jsx';
import { EmptyState } from './ui/misc.jsx';
import { import {
Chart as ChartJS, CategoryScale, LinearScale, PointElement, Chart as ChartJS, CategoryScale, LinearScale, BarElement, PointElement,
LineElement, ArcElement, Tooltip, Legend LineElement, ArcElement, Tooltip, Legend
} from 'chart.js'; } from 'chart.js';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Tooltip, Legend); ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, ArcElement, Tooltip, Legend);
const fmtBytes = (b) => { const fmtBytes = (b) => {
if (!b) return '0 B'; if (!b) return '0 B';
@@ -80,6 +77,32 @@ export function VolumeWidget({ volume }) {
); );
} }
export function HeatmapWidget({ heatmap }) {
if (!heatmap) return <Empty />;
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const max = Math.max(1, ...heatmap.map((c) => c.count));
const grid = {};
heatmap.forEach((c) => { grid[`${c.dayOfWeek}-${c.hour}`] = c.count; });
return (
<div className="widget">
<h3>Activity Heatmap</h3>
<div className="heatmap">
{days.map((d, dow) => (
<div className="hm-row" key={dow}>
<span className="hm-day">{d}</span>
{Array.from({ length: 24 }, (_, h) => {
const v = grid[`${dow}-${h}`] || 0;
return <span key={h} className="hm-cell" style={{ opacity: 0.1 + 0.9 * (v / max) }} title={`${d} ${h}:00 — ${v}`} />;
})}
</div>
))}
</div>
</div>
);
}
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// Maps EmailCategory enum name → folder slug or search query // Maps EmailCategory enum name → folder slug or search query
const CAT_SLUG = { const CAT_SLUG = {
Finance: '/app/folder/finance', Finance: '/app/folder/finance',
@@ -112,64 +135,41 @@ const CAT_SLUG = {
Unknown: '/app/folder/allmail', Unknown: '/app/folder/allmail',
}; };
// "Emails by Category" — ranked horizontal bar list. Aggregates the export function CategoryHeatmapWidget() {
// category×day-of-week cells into per-category totals; each bar links to
// that category's folder via CAT_SLUG.
export function CategoryBreakdownWidget() {
const [cells, setCells] = useState(null); const [cells, setCells] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { AnalyticsApi.categoryHeatmap().then(setCells).catch(() => setCells([])); }, []); useEffect(() => { AnalyticsApi.categoryHeatmap().then(setCells).catch(() => setCells([])); }, []);
if (!cells) { if (!cells) return <div className="widget"><div className="muted">Loading</div></div>;
return ( if (cells.length === 0) return <div className="widget"><h3>Category Heatmap</h3><div className="muted">No data yet run a sync.</div></div>;
<div className="widget">
<h3>Emails by Category</h3>
<div className="cat-bars">
{Array.from({ length: 6 }, (_, i) => <Skeleton key={i} className="h-6 w-full" />)}
</div>
</div>
);
}
const totals = {}; const categories = [...new Set(cells.map((c) => c.category))].sort();
cells.forEach((c) => { totals[c.category] = (totals[c.category] || 0) + c.count; }); const max = Math.max(1, ...cells.map((c) => c.count));
const ranked = Object.entries(totals) const grid = {};
.map(([category, count]) => ({ category, count })) cells.forEach((c) => { grid[`${c.category}-${c.dayOfWeek}`] = c.count; });
.sort((a, b) => b.count - a.count)
.slice(0, 8);
if (ranked.length === 0) {
return (
<div className="widget">
<h3>Emails by Category</h3>
<EmptyState icon={Inbox} title="No data yet — run a sync" description="Category totals appear once your inbox has been synced." />
</div>
);
}
const max = Math.max(1, ...ranked.map((r) => r.count));
return ( return (
<div className="widget"> <div className="widget">
<h3>Emails by Category</h3> <h3>Category Heatmap</h3>
<div className="cat-bars"> <div className="cat-heatmap">
{ranked.map(({ category, count }) => { <div className="chm-row chm-head">
const dest = CAT_SLUG[category]; <span className="chm-label" />
{DOW.map((d) => <span key={d} className="chm-col">{d}</span>)}
</div>
{categories.map((cat) => {
const dest = CAT_SLUG[cat];
return ( return (
<button <div className="chm-row" key={cat}>
type="button" <span
className={`cat-bar${dest ? ' cat-bar--link' : ''}`} className={`chm-label${dest ? ' chm-label--link' : ''}`}
key={category} title={dest ? `View ${cat} emails` : cat}
disabled={!dest} onClick={dest ? () => navigate(dest) : undefined}
title={dest ? `View ${category} emails` : category} >{cat}</span>
onClick={dest ? () => navigate(dest) : undefined} {DOW.map((_, dow) => {
> const v = grid[`${cat}-${dow}`] || 0;
<span className="cat-bar-label">{category}</span> return <span key={dow} className="chm-cell" style={{ opacity: 0.12 + 0.88 * (v / max) }} title={`${cat} · ${DOW[dow]}${v}`}>{v || ''}</span>;
<span className="cat-bar-track"> })}
<span className="cat-bar-fill" style={{ width: `${(count / max) * 100}%` }} /> </div>
</span>
<span className="cat-bar-count">{count.toLocaleString()}</span>
</button>
); );
})} })}
</div> </div>
@@ -196,10 +196,4 @@ export function StorageWidget({ bytes }) {
return <StatCard label="Estimated Storage" value={fmtBytes(bytes)} />; return <StatCard label="Estimated Storage" value={fmtBytes(bytes)} />;
} }
function Empty() { function Empty() { return <div className="widget"><div className="muted">No data yet run a sync.</div></div>; }
return (
<div className="widget">
<EmptyState icon={Inbox} title="No data yet — run a sync" description="This widget populates after your inbox has been synced." />
</div>
);
}
+23 -57
View File
@@ -2,15 +2,9 @@ import { useEffect, useState } from 'react';
import { BulkApi } from '../api/client.js'; import { BulkApi } from '../api/client.js';
/// <summary> /// <summary>
/// Keyboard navigation for an email list. Shortcuts: /// j/k move focus down/up the list, e archives the focused email, # (shift+3)
/// j / ArrowDown move focus down /// trashes it. Ignored while an input/textarea/select has focus, or while
/// k / ArrowUp move focus up /// the "/" search shortcut is active, so typing is never hijacked.
/// e archive the focused email
/// u mark the focused email unread
/// # (shift+3) trash the focused email
/// All shortcuts are ignored while an input/textarea/select (or any
/// contenteditable) has focus, and modifier chords (Ctrl/Cmd/Alt) are left
/// alone, so typing and browser/OS shortcuts are never hijacked.
/// `onRemoved(id)` lets the caller drop the row from local state after a /// `onRemoved(id)` lets the caller drop the row from local state after a
/// successful archive/trash. /// successful archive/trash.
/// </summary> /// </summary>
@@ -18,59 +12,31 @@ export default function useListKeyboardNav(emails, onRemoved) {
const [focusedId, setFocusedId] = useState(null); const [focusedId, setFocusedId] = useState(null);
useEffect(() => { useEffect(() => {
const isEditable = (el) => {
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
};
const handler = async (e) => { const handler = async (e) => {
// Never hijack typing or modifier chords (Ctrl+C, Cmd+K, Alt+…). const tag = document.activeElement?.tagName;
if (isEditable(document.activeElement)) return; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (!emails.length) return; if (!emails.length) return;
const idx = emails.findIndex((x) => x.id === focusedId); const idx = emails.findIndex((x) => x.id === focusedId);
switch (e.key) { if (e.key === 'j') {
case 'j': e.preventDefault();
case 'ArrowDown': { const next = idx < 0 ? 0 : Math.min(idx + 1, emails.length - 1);
e.preventDefault(); setFocusedId(emails[next].id);
const next = idx < 0 ? 0 : Math.min(idx + 1, emails.length - 1); } else if (e.key === 'k') {
setFocusedId(emails[next].id); e.preventDefault();
break; const prev = idx < 0 ? 0 : Math.max(idx - 1, 0);
} setFocusedId(emails[prev].id);
case 'k': } else if (e.key === 'e' && idx >= 0) {
case 'ArrowUp': { e.preventDefault();
e.preventDefault(); const id = emails[idx].id;
const prev = idx < 0 ? 0 : Math.max(idx - 1, 0); await BulkApi.archive([id]);
setFocusedId(emails[prev].id); onRemoved(id);
break; } else if (e.key === '#' && idx >= 0) {
} e.preventDefault();
case 'e': { const id = emails[idx].id;
if (idx < 0) break; await BulkApi.trash([id]);
e.preventDefault(); onRemoved(id);
const id = emails[idx].id;
await BulkApi.archive([id]);
onRemoved(id);
break;
}
case 'u': {
if (idx < 0) break;
e.preventDefault();
await BulkApi.markUnread([emails[idx].id]);
break;
}
case '#': {
if (idx < 0) break;
e.preventDefault();
const id = emails[idx].id;
await BulkApi.trash([id]);
onRemoved(id);
break;
}
default:
break;
} }
}; };
-1
View File
@@ -7,7 +7,6 @@ import { ToastProvider, TooltipProvider } from './components/ui';
import '@fontsource-variable/inter'; import '@fontsource-variable/inter';
import './index.css'; import './index.css';
import './styles.css'; import './styles.css';
import './split.css';
// Route-level code splitting: each page loads its own chunk on first visit, so the // Route-level code splitting: each page loads its own chunk on first visit, so the
// initial bundle no longer carries Chart.js / grid-layout / every page at once. // initial bundle no longer carries Chart.js / grid-layout / every page at once.
+10 -25
View File
@@ -6,9 +6,8 @@ import { AnalyticsApi, LayoutApi, ExportApi, SyncApi } from '../api/client.js';
import SyncSplash from '../components/SyncSplash.jsx'; import SyncSplash from '../components/SyncSplash.jsx';
import { import {
HealthWidget, StatCard, TopSendersWidget, VolumeWidget, HealthWidget, StatCard, TopSendersWidget, VolumeWidget,
CategoryBreakdownWidget, AttachmentsWidget, StorageWidget CategoryHeatmapWidget, AttachmentsWidget, StorageWidget
} from '../components/widgets.jsx'; } from '../components/widgets.jsx';
import { Skeleton } from '../components/ui/skeleton.jsx';
// Default grid geometry; overridden by the user's saved layout. // Default grid geometry; overridden by the user's saved layout.
const DEFAULT_LAYOUT = [ const DEFAULT_LAYOUT = [
@@ -18,23 +17,12 @@ const DEFAULT_LAYOUT = [
{ i: 'storage', x: 7, y: 0, w: 2, h: 2 }, { i: 'storage', x: 7, y: 0, w: 2, h: 2 },
{ i: 'top-senders', x: 3, y: 2, w: 3, h: 5 }, { i: 'top-senders', x: 3, y: 2, w: 3, h: 5 },
{ i: 'volume', x: 6, y: 2, w: 6, h: 4 }, { i: 'volume', x: 6, y: 2, w: 6, h: 4 },
{ i: 'category-breakdown', x: 0, y: 5, w: 6, h: 5 }, { i: 'category-heatmap', x: 0, y: 5, w: 6, h: 5 },
{ i: 'attachments', x: 6, y: 6, w: 4, h: 4 }, { i: 'attachments', x: 6, y: 6, w: 4, h: 4 },
]; ];
const ALL_WIDGETS = DEFAULT_LAYOUT.map((l) => l.i); const ALL_WIDGETS = DEFAULT_LAYOUT.map((l) => l.i);
function WidgetSkeleton() {
return (
<div className="widget">
<Skeleton className="mb-3 h-4 w-1/3" />
<Skeleton className="mb-2 h-3 w-full" />
<Skeleton className="mb-2 h-3 w-5/6" />
<Skeleton className="h-3 w-2/3" />
</div>
);
}
export default function Dashboard() { export default function Dashboard() {
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [layout, setLayout] = useState(DEFAULT_LAYOUT); const [layout, setLayout] = useState(DEFAULT_LAYOUT);
@@ -99,18 +87,15 @@ export default function Dashboard() {
const visibleLayout = useMemo(() => layout.filter((l) => !hidden.includes(l.i)), [layout, hidden]); const visibleLayout = useMemo(() => layout.filter((l) => !hidden.includes(l.i)), [layout, hidden]);
const render = (key) => { const render = (key) => {
// category-breakdown self-fetches, so it renders regardless of dashboard load state.
if (key === 'category-breakdown') return <CategoryBreakdownWidget />;
// While the dashboard payload loads, show a skeleton placeholder per widget.
if (!data) return <WidgetSkeleton />;
switch (key) { switch (key) {
case 'inbox-health': return <HealthWidget health={data.health} />; case 'inbox-health': return <HealthWidget health={data?.health} />;
case 'total-emails': return <StatCard label="Total Emails" value={(data.totalEmails ?? 0).toLocaleString()} to="/app/folder/allmail" />; case 'total-emails': return <StatCard label="Total Emails" value={(data?.totalEmails ?? 0).toLocaleString()} to="/app/folder/allmail" />;
case 'unread-emails': return <StatCard label="Unread" value={(data.unreadEmails ?? 0).toLocaleString()} to="/app/folder/unread" />; case 'unread-emails': return <StatCard label="Unread" value={(data?.unreadEmails ?? 0).toLocaleString()} to="/app/folder/unread" />;
case 'storage': return <StorageWidget bytes={data.storageEstimateBytes} />; case 'storage': return <StorageWidget bytes={data?.storageEstimateBytes} />;
case 'top-senders': return <TopSendersWidget senders={data.topSenders} />; case 'top-senders': return <TopSendersWidget senders={data?.topSenders} />;
case 'volume': return <VolumeWidget volume={data.volumeOverTime} />; case 'volume': return <VolumeWidget volume={data?.volumeOverTime} />;
case 'attachments': return <AttachmentsWidget attachments={data.attachmentBreakdown} />; case 'category-heatmap': return <CategoryHeatmapWidget />;
case 'attachments': return <AttachmentsWidget attachments={data?.attachmentBreakdown} />;
default: return null; default: return null;
} }
}; };
+25 -66
View File
@@ -2,9 +2,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { SearchApi } from '../api/client.js'; import { SearchApi } from '../api/client.js';
import EmailRow from '../components/EmailRow.jsx'; import EmailRow from '../components/EmailRow.jsx';
import EmailDetail from '../components/EmailDetail.jsx';
import BulkToolbar from '../components/BulkToolbar.jsx'; import BulkToolbar from '../components/BulkToolbar.jsx';
import { Skeleton, EmptyState } from '../components/ui';
import useSelection from '../hooks/useSelection.js'; import useSelection from '../hooks/useSelection.js';
import useListKeyboardNav from '../hooks/useListKeyboardNav.js'; import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
@@ -37,20 +35,6 @@ const FOLDER_META = {
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
function ListSkeleton({ rows = 6 }) {
return (
<div className="sv-skeleton-list" aria-hidden="true">
{Array.from({ length: rows }).map((_, i) => (
<div className="sv-skeleton-row" key={i}>
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
);
}
export default function FolderView() { export default function FolderView() {
const { slug } = useParams(); const { slug } = useParams();
@@ -62,7 +46,6 @@ export default function FolderView() {
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [selectedEmail, setSelectedEmail] = useState(null);
const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id))); const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id)));
@@ -73,7 +56,6 @@ export default function FolderView() {
setPage(1); setPage(1);
setHasMore(true); setHasMore(true);
setError(null); setError(null);
setSelectedEmail(null);
clear(); clear();
}, [slug, clear]); }, [slug, clear]);
@@ -133,57 +115,34 @@ export default function FolderView() {
}} }}
/> />
<div className={`sv-split${selectedEmail ? ' sv-split--open' : ''}`}> {!loading && !error && emails.length === 0 && (
<div className="sv-list-pane"> <div className="fv-empty">No emails in this folder.</div>
{loading && emails.length === 0 && !error && <ListSkeleton />} )}
{!loading && !error && emails.length === 0 && ( {emails.length > 0 && (
<EmptyState <table className="email-list">
title="No emails in this folder" <tbody>
description="Nothing here yet — try another folder or run a sync." {emails.map((e) => (
/> <EmailRow
)} key={e.id}
email={e}
selected={selected.has(e.id)}
onToggleSelect={toggle}
focused={focusedId === e.id}
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
/>
))}
</tbody>
</table>
)}
{emails.length > 0 && ( {/* Sentinel — triggers next page load when scrolled into view */}
<table className="email-list"> <div ref={sentinelRef} className="fv-sentinel" />
<tbody>
{emails.map((e) => (
<EmailRow
key={e.id}
email={e}
selected={selected.has(e.id)}
onToggleSelect={toggle}
focused={focusedId === e.id}
onOpen={(email) => setSelectedEmail(email)}
onRemove={(id) => {
setEmails((prev) => prev.filter((x) => x.id !== id));
setSelectedEmail((cur) => (cur?.id === id ? null : cur));
}}
/>
))}
</tbody>
</table>
)}
{/* Sentinel — triggers next page load when scrolled into view */} {loading && <div className="fv-loading-more">Loading</div>}
<div ref={sentinelRef} className="fv-sentinel" /> {!hasMore && emails.length > 0 && (
<div className="fv-end"> {emails.length.toLocaleString()} emails </div>
{loading && emails.length > 0 && <div className="fv-loading-more">Loading</div>} )}
{!hasMore && emails.length > 0 && (
<div className="fv-end"> {emails.length.toLocaleString()} emails </div>
)}
</div>
{selectedEmail && (
<aside className="sv-reading-pane">
<EmailDetail
key={selectedEmail.id}
email={selectedEmail}
onClose={() => setSelectedEmail(null)}
/>
</aside>
)}
</div>
</div> </div>
); );
} }
+23 -72
View File
@@ -2,29 +2,13 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { SearchApi } from '../api/client.js'; import { SearchApi } from '../api/client.js';
import EmailRow from '../components/EmailRow.jsx'; import EmailRow from '../components/EmailRow.jsx';
import EmailDetail from '../components/EmailDetail.jsx';
import BulkToolbar from '../components/BulkToolbar.jsx'; import BulkToolbar from '../components/BulkToolbar.jsx';
import { Skeleton, EmptyState } from '../components/ui';
import useSelection from '../hooks/useSelection.js'; import useSelection from '../hooks/useSelection.js';
import useListKeyboardNav from '../hooks/useListKeyboardNav.js'; import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
import useSavedSearches from '../hooks/useSavedSearches.js'; import useSavedSearches from '../hooks/useSavedSearches.js';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
function ListSkeleton({ rows = 6 }) {
return (
<div className="sv-skeleton-list" aria-hidden="true">
{Array.from({ length: rows }).map((_, i) => (
<div className="sv-skeleton-row" key={i}>
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
);
}
export default function SearchResults() { export default function SearchResults() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -36,7 +20,6 @@ export default function SearchResults() {
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [selectedEmail, setSelectedEmail] = useState(null);
const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id))); const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id)));
const { searches: savedSearches, add: addSavedSearch } = useSavedSearches(); const { searches: savedSearches, add: addSavedSearch } = useSavedSearches();
@@ -53,7 +36,6 @@ export default function SearchResults() {
setPage(1); setPage(1);
setHasMore(true); setHasMore(true);
setError(null); setError(null);
setSelectedEmail(null);
clear(); clear();
}, [q, clear]); }, [q, clear]);
@@ -101,13 +83,11 @@ export default function SearchResults() {
)} )}
</div> </div>
{!q.trim() && ( {!q.trim() && <div className="fv-empty">Enter a search query above.</div>}
<EmptyState
title="Search your mail"
description="Enter a search query above to find emails."
/>
)}
{error && <div className="fv-error">{error}</div>} {error && <div className="fv-error">{error}</div>}
{!loading && !error && q.trim() && emails.length === 0 && !hasMore && (
<div className="fv-empty">No results for "{q}".</div>
)}
<BulkToolbar <BulkToolbar
selectedIds={selectedIds} selectedIds={selectedIds}
@@ -120,56 +100,27 @@ export default function SearchResults() {
}} }}
/> />
{q.trim() && ( {emails.length > 0 && (
<div className={`sv-split${selectedEmail ? ' sv-split--open' : ''}`}> <table className="email-list">
<div className="sv-list-pane"> <tbody>
{loading && emails.length === 0 && !error && <ListSkeleton />} {emails.map((e) => (
<EmailRow
{!loading && !error && emails.length === 0 && !hasMore && ( key={e.id}
<EmptyState email={e}
title={`No results for "${q}"`} selected={selected.has(e.id)}
description="Try a different search term or filter." onToggleSelect={toggle}
focused={focusedId === e.id}
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
/> />
)} ))}
</tbody>
</table>
)}
{emails.length > 0 && ( <div ref={sentinelRef} className="fv-sentinel" />
<table className="email-list"> {loading && <div className="fv-loading-more">Loading</div>}
<tbody> {!hasMore && emails.length > 0 && (
{emails.map((e) => ( <div className="fv-end"> {emails.length.toLocaleString()} results </div>
<EmailRow
key={e.id}
email={e}
selected={selected.has(e.id)}
onToggleSelect={toggle}
focused={focusedId === e.id}
onOpen={(email) => setSelectedEmail(email)}
onRemove={(id) => {
setEmails((prev) => prev.filter((x) => x.id !== id));
setSelectedEmail((cur) => (cur?.id === id ? null : cur));
}}
/>
))}
</tbody>
</table>
)}
<div ref={sentinelRef} className="fv-sentinel" />
{loading && emails.length > 0 && <div className="fv-loading-more">Loading</div>}
{!hasMore && emails.length > 0 && (
<div className="fv-end"> {emails.length.toLocaleString()} results </div>
)}
</div>
{selectedEmail && (
<aside className="sv-reading-pane">
<EmailDetail
key={selectedEmail.id}
email={selectedEmail}
onClose={() => setSelectedEmail(null)}
/>
</aside>
)}
</div>
)} )}
</div> </div>
); );
-148
View File
@@ -1,148 +0,0 @@
/* ── Split-view reading pane ────────────────────────────────────────────────
* Owned by Unit 1. Gmail/Outlook-style master-detail: the email list stays on
* the left, a collapsible + horizontally resizable reading pane sits on the
* right. Below 768px the pane overlays the list (mobile stack) instead of
* squishing the columns side-by-side.
*
* These pages (.folder-view) are styled from styles.css, so we reference its
* legacy CSS variables (--panel, --panel-2, --text, --muted, --accent) with
* hardcoded hex fallbacks. The modern index.css tokens are stored as raw HSL
* channel triplets and only work via hsl(var(--x)), so they are NOT used bare
* here.
* ------------------------------------------------------------------------- */
.sv-split {
display: flex;
align-items: stretch;
gap: 0;
width: 100%;
}
/* Left column — the list. Flexes to fill remaining space and scrolls itself. */
.sv-list-pane {
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
}
/* Right column — the reading pane. Resizable via the native handle; the width
* gives it a sensible default, and the user can drag the bottom-right corner.
* `resize: horizontal` needs `overflow` other than visible. */
.sv-reading-pane {
flex: 0 0 auto;
width: clamp(320px, 42%, 640px);
min-width: 300px;
max-width: 80vw;
border-left: 1px solid var(--panel-2, #222a3d);
overflow: auto;
resize: horizontal;
background: var(--panel, #1a2030);
align-self: stretch;
}
/* ── Reading pane inner content ─────────────────────────────────────────── */
.sv-detail { padding: 18px 20px; }
.sv-detail-topbar {
display: flex;
justify-content: flex-end;
margin-bottom: 6px;
}
.sv-close {
background: transparent;
border: 1px solid var(--panel-2, #222a3d);
color: var(--muted, #8b93a7);
border-radius: 6px;
width: 28px;
height: 28px;
font-size: 13px;
line-height: 1;
cursor: pointer;
}
.sv-close:hover {
background: var(--panel-2, #222a3d);
color: var(--text, #e6e9f0);
}
.sv-detail-header { margin-bottom: 14px; }
.sv-detail-subject {
font-size: 18px;
font-weight: 700;
margin-bottom: 6px;
color: var(--text, #e6e9f0);
word-break: break-word;
}
.sv-detail-meta {
font-size: 12px;
color: var(--muted, #8b93a7);
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.sv-detail-sep { opacity: 0.4; }
.sv-ai-summary { margin-bottom: 14px; }
.sv-ai-summary-text {
background: var(--panel-2, #222a3d);
border: 1px solid var(--panel-2, #222a3d);
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
color: var(--text, #e6e9f0);
}
.sv-body-loading { padding: 8px 0 18px; }
.sv-body {
background: var(--panel, #1a2030);
border: 1px solid var(--panel-2, #222a3d);
border-radius: 8px;
padding: 16px 18px;
font-size: 13px;
line-height: 1.7;
color: var(--text, #e6e9f0);
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 18px;
max-height: 60vh;
overflow-y: auto;
}
.sv-snippet {
font-size: 13px;
line-height: 1.6;
color: var(--muted, #8b93a7);
margin-bottom: 18px;
}
.sv-detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
/* ── List loading / empty states ───────────────────────────────────────── */
.sv-skeleton-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 0;
}
.sv-skeleton-row {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 8px;
}
/* ── Mobile: stack / overlay the reading pane ──────────────────────────── */
@media (max-width: 767px) {
.sv-reading-pane {
position: fixed;
inset: 56px 0 0 0; /* below the topbar */
width: 100% !important;
max-width: 100vw;
min-width: 0;
border-left: none;
resize: none;
z-index: 40;
}
/* When the pane is open, hide the underlying list to avoid double-scroll. */
.sv-split.sv-split--open .sv-list-pane {
display: none;
}
}
+27 -53
View File
@@ -117,6 +117,8 @@ button:disabled { opacity: 0.5; cursor: default; }
.widget--link:hover { border-color: var(--accent); } .widget--link:hover { border-color: var(--accent); }
.mini-row--link { cursor: pointer; } .mini-row--link { cursor: pointer; }
.mini-row--link:hover td { color: var(--accent); } .mini-row--link:hover td { color: var(--accent); }
.chm-label--link { cursor: pointer; text-decoration: underline dotted; }
.chm-label--link:hover { color: var(--accent); }
.widget canvas { flex: 1; min-height: 0; } .widget canvas { flex: 1; min-height: 0; }
.stat { align-items: flex-start; justify-content: center; } .stat { align-items: flex-start; justify-content: center; }
@@ -135,21 +137,17 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #332f2b; t
.num { text-align: right; } .num { text-align: right; }
.muted { color: var(--muted); font-size: 12px; } .muted { color: var(--muted); font-size: 12px; }
/* Emails by Category — ranked horizontal bars */ .heatmap { display: flex; flex-direction: column; gap: 2px; }
.cat-bars { display: flex; flex-direction: column; gap: 6px; overflow: auto; } .hm-row { display: flex; align-items: center; gap: 2px; }
.cat-bar { .hm-day { width: 30px; font-size: 10px; color: var(--muted); }
display: grid; grid-template-columns: 92px 1fr 40px; gap: 8px; align-items: center; .hm-cell { width: 10px; height: 10px; background: var(--accent); border-radius: 2px; }
width: 100%; padding: 3px 4px; margin: 0; border: none; background: transparent;
border-radius: 6px; text-align: left; font: inherit; color: inherit; /* Category heatmap */
} .cat-heatmap { display: flex; flex-direction: column; gap: 3px; overflow: auto; }
.cat-bar--link { cursor: pointer; } .chm-row { display: grid; grid-template-columns: 92px repeat(7, 1fr); gap: 3px; align-items: stretch; }
.cat-bar--link:hover { background: rgba(255, 255, 255, 0.04); } .chm-head .chm-col { font-size: 10px; color: var(--muted); text-align: center; }
.cat-bar:disabled { cursor: default; } .chm-label { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cat-bar-label { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .chm-cell { background: var(--accent); border-radius: 3px; min-height: 22px; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #fff; }
.cat-bar-track { height: 14px; background: rgba(255, 255, 255, 0.06); border-radius: 4px; overflow: hidden; }
.cat-bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; min-width: 2px; }
.cat-bar-count { font-size: 11px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
.cat-bar--link:hover .cat-bar-label { color: var(--accent); }
/* react-grid-layout resize handle — make it clearly visible on the dark theme */ /* react-grid-layout resize handle — make it clearly visible on the dark theme */
.react-resizable-handle { .react-resizable-handle {
@@ -298,57 +296,32 @@ input, select { background: var(--panel-2); border: 1px solid #332f2b; color: va
.fv-error { color: var(--danger); font-size: 14px; padding: 12px 0; } .fv-error { color: var(--danger); font-size: 14px; padding: 12px 0; }
.email-list { width: 100%; border-collapse: collapse; font-size: 13px; } .email-list { width: 100%; border-collapse: collapse; font-size: 13px; }
.email-row { .email-row { border-bottom: 1px solid #332f2b; cursor: pointer; }
height: 44px;
border-bottom: 1px solid #332f2b;
cursor: pointer;
transition: background 0.1s ease;
}
.email-row > td { padding-top: 0; padding-bottom: 0; vertical-align: middle; }
/* Single, consistent hover state for the whole row. */
.email-row:hover { background: var(--panel); } .email-row:hover { background: var(--panel); }
/* Unread: prominent subject, keep sender readable but not shouty. */ .email-row--unread .el-sender,
.email-row--unread .el-subj-text { font-weight: 700; color: var(--text); } .email-row--unread .el-subj-text { font-weight: 700; color: var(--text); }
.email-row--unread .el-sender { font-weight: 600; color: var(--text); }
.el-select { width: 34px; padding: 0 4px 0 10px; text-align: center; } .el-unread { width: 14px; padding: 10px 4px 10px 0; }
.el-select > * { vertical-align: middle; }
.el-unread { width: 14px; padding: 0 4px 0 0; text-align: center; }
.unread-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); } .unread-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
.el-sender { width: 180px; padding: 10px 12px 10px 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
/* Sender: secondary in the hierarchy — muted by default. */ .el-subject { padding: 10px 8px; overflow: hidden; }
.el-sender {
width: 180px; padding: 0 12px 0 4px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
color: var(--muted); font-size: 12.5px;
}
/* Subject + snippet share one line: subject prominent, snippet muted. */
.el-subject { padding: 0 8px; overflow: hidden; max-width: 0; white-space: nowrap; text-overflow: ellipsis; }
.el-subj-text { color: var(--text); } .el-subj-text { color: var(--text); }
.el-snippet { .el-snippet { color: var(--muted); }
color: var(--muted); font-size: 12.5px; .el-meta { width: 80px; padding: 10px 8px; text-align: right; white-space: nowrap; }
}
.el-snippet::before { content: '—'; margin: 0 6px; opacity: 0.55; }
.el-meta { width: 80px; padding: 0 8px; text-align: right; white-space: nowrap; }
.el-attach { margin-right: 4px; font-size: 12px; } .el-attach { margin-right: 4px; font-size: 12px; }
.el-size { font-size: 11px; color: var(--muted); } .el-size { font-size: 11px; color: var(--muted); }
.el-date { width: 70px; padding: 0 0 0 8px; text-align: right; color: var(--muted); white-space: nowrap; font-size: 12px; } .el-date { width: 70px; padding: 10px 0 10px 8px; text-align: right; color: var(--muted); white-space: nowrap; font-size: 12px; }
.el-actions { width: 96px; padding: 0 6px; text-align: right; white-space: nowrap; } .el-actions { width: 80px; padding: 0 6px; text-align: right; white-space: nowrap; }
.action-btn { .action-btn {
background: none; border: none; padding: 4px 5px; cursor: pointer; background: none; border: none; padding: 3px 4px; cursor: pointer;
font-size: 13px; line-height: 1; opacity: 0; font-size: 13px; opacity: 0; transition: opacity 0.1s, color 0.1s;
transition: opacity 0.1s ease, color 0.1s ease, background 0.1s ease;
border-radius: 4px; color: var(--muted); border-radius: 4px; color: var(--muted);
} }
.action-btn:hover { background: var(--panel-2); color: var(--text); opacity: 1 !important; } .action-btn:hover { background: var(--panel-2); opacity: 1 !important; }
.action-btn--active { opacity: 1 !important; } .action-btn--active { opacity: 1 !important; }
.action-btn--danger:hover { color: var(--danger); } .action-btn--danger:hover { color: var(--danger); }
.email-row:hover .action-btn { opacity: 0.65; } .email-row:hover .action-btn { opacity: 0.6; }
.action-btn:focus-visible { opacity: 1 !important; outline: 2px solid var(--accent); outline-offset: 1px; }
.email-row--acting { opacity: 0.6; pointer-events: none; } .email-row--acting { opacity: 0.6; pointer-events: none; }
.action-btn--unsub { font-size: 11px; } .action-btn--unsub { font-size: 11px; }
.action-btn--done { opacity: 1 !important; color: var(--ok); } .action-btn--done { opacity: 1 !important; color: var(--ok); }
@@ -432,6 +405,7 @@ input, select { background: var(--panel-2); border: 1px solid #332f2b; color: va
} }
.bulk-btn:hover { border-color: var(--accent); } .bulk-btn:hover { border-color: var(--accent); }
.bulk-btn--danger:hover { border-color: var(--danger); color: var(--danger); } .bulk-btn--danger:hover { border-color: var(--danger); color: var(--danger); }
.el-select { width: 28px; text-align: center; }
/* ── Keyboard shortcut help ──────────────────────────────────────────────── */ /* ── Keyboard shortcut help ──────────────────────────────────────────────── */
.kbd-hint { position: fixed; bottom: 14px; right: 14px; font-size: 11px; color: var(--muted); background: var(--panel); border: 1px solid #332f2b; border-radius: 6px; padding: 4px 10px; opacity: 0.7; } .kbd-hint { position: fixed; bottom: 14px; right: 14px; font-size: 11px; color: var(--muted); background: var(--panel); border: 1px solid #332f2b; border-radius: 6px; padding: 4px 10px; opacity: 0.7; }
@@ -72,22 +72,6 @@ public class EmailController : ApiControllerBase
[HttpPost("{id:guid}/untrash")] [HttpPost("{id:guid}/untrash")]
public Task<IActionResult> Untrash(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Archive, ct); public Task<IActionResult> Untrash(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Archive, ct);
/// <summary>Toggle the local-only Read Later marker (no Gmail side effect).</summary>
[HttpPost("{id:guid}/readlater")]
public Task<IActionResult> ReadLater(Guid id, CancellationToken ct) => SetReadLater(id, true, ct);
[HttpPost("{id:guid}/unreadlater")]
public Task<IActionResult> UnreadLater(Guid id, CancellationToken ct) => SetReadLater(id, false, ct);
private async Task<IActionResult> SetReadLater(Guid id, bool value, CancellationToken ct)
{
var email = await _db.Emails.FirstOrDefaultAsync(e => e.Id == id && e.UserId == UserId, ct);
if (email is null) return NotFound();
email.IsReadLater = value;
await _db.SaveChangesAsync(ct);
return Ok();
}
/// <summary> /// <summary>
/// Inline unsubscribe. Detects the unsubscribe mechanism for the email's sender, /// Inline unsubscribe. Detects the unsubscribe mechanism for the email's sender,
/// then executes it (HTTP one-click or HTTP link). mailto targets cannot be sent /// then executes it (HTTP one-click or HTTP link). mailto targets cannot be sent
-8
View File
@@ -15,14 +15,6 @@ RUN dotnet publish src/InboxIntel.Api/InboxIntel.Api.csproj -c Release -o /app/p
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app WORKDIR /app
# The slim aspnet:10.0 image dropped libgssapi_krb5, which Npgsql tries to load during
# connection negotiation ("Cannot load library libgssapi_krb5.so.2"). Harmless for password
# auth but noisy and a latent failure on some paths — install the Kerberos runtime lib.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libgssapi-krb5-2 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish . COPY --from=build /app/publish .
# V-12: run as the non-root 'app' user shipped in the .NET 8 images. Pre-create the # V-12: run as the non-root 'app' user shipped in the .NET 8 images. Pre-create the
@@ -21,15 +21,7 @@ public record SearchRequestDto(
bool? IsInInbox = null, bool? IsInInbox = null,
bool? IsStarred = null, bool? IsStarred = null,
bool? IsTrashed = null, bool? IsTrashed = null,
bool? IsImportant = null, // Gmail "important" marker — backs the Pinned smart folder
bool? IsReadLater = null, // local Read Later marker
string? GmailLabel = null, // e.g. "SENT", "DRAFT", "SPAM" string? GmailLabel = null, // e.g. "SENT", "DRAFT", "SPAM"
// Emails carrying NONE of these Gmail labels (by GmailLabelId). Backs Archive, which
// must exclude Sent/Spam/Draft/Chat/Trash rather than just "not in inbox".
IReadOnlyList<string>? ExcludeGmailLabels = null,
// true = only emails with at least one USER label; false = only emails with NO user
// labels (the Unlabelled folder). System labels (INBOX/SENT/…) don't count.
bool? HasUserLabels = null,
string? Category = null, // EmailCategory name, e.g. "Finance" string? Category = null, // EmailCategory name, e.g. "Finance"
long? MinSizeBytes = null, long? MinSizeBytes = null,
// Keyset cursor for the date-ordered browse path (RECOMMENDATIONS #8): pass the last // Keyset cursor for the date-ordered browse path (RECOMMENDATIONS #8): pass the last
-4
View File
@@ -40,10 +40,6 @@ public class Email : AuditableEntity
public bool IsTrashed { get; set; } public bool IsTrashed { get; set; }
public bool HasAttachments { get; set; } public bool HasAttachments { get; set; }
/// <summary>Local "Read Later" marker (not a Gmail concept) — toggled by the user so the
/// Read Later smart folder shows only explicitly flagged mail, never everything.</summary>
public bool IsReadLater { get; set; }
// Unsubscribe signals captured at parse time. // Unsubscribe signals captured at parse time.
public bool HasListUnsubscribe { get; set; } public bool HasListUnsubscribe { get; set; }
public string? ListUnsubscribeRaw { get; set; } public string? ListUnsubscribeRaw { get; set; }
@@ -95,9 +95,7 @@ public class GmailApiService : IGmailService
var req = client.Users.Messages.List("me"); var req = client.Users.Messages.List("me");
req.MaxResults = _options.PageSize; req.MaxResults = _options.PageSize;
req.PageToken = pageToken; req.PageToken = pageToken;
// Include spam & trash so those folders aren't structurally empty; their state is req.IncludeSpamTrash = false;
// captured via the SPAM/TRASH labels (IsTrashed + EmailLabels) during upsert.
req.IncludeSpamTrash = true;
return await req.ExecuteAsync(token); return await req.ExecuteAsync(token);
}, ct); }, ct);
@@ -1,834 +0,0 @@
// <auto-generated />
using System;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using NpgsqlTypes;
using Pgvector;
#nullable disable
namespace InboxIntel.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260704141744_AddIsReadLater")]
partial class AddIsReadLater
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("InboxIntel.Domain.Entities.AnalyticsAggregate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("Day")
.HasColumnType("date");
b.Property<string>("HourHistogramJson")
.HasColumnType("text");
b.Property<int>("NewsletterCount")
.HasColumnType("integer");
b.Property<int>("TotalReceived")
.HasColumnType("integer");
b.Property<long>("TotalSizeBytes")
.HasColumnType("bigint");
b.Property<int>("TotalUnread")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<int>("WithAttachments")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("UserId", "Day")
.IsUnique();
b.ToTable("analytics_aggregates", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("EmailId")
.HasColumnType("uuid");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("GmailAttachmentId")
.HasColumnType("text");
b.Property<string>("MimeType")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("EmailId");
b.HasIndex("UserId", "MimeType");
b.ToTable("attachments", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BodyText")
.HasColumnType("text");
b.Property<int>("Category")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Vector>("Embedding")
.HasColumnType("vector(768)");
b.Property<string>("GmailMessageId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("HasAttachments")
.HasColumnType("boolean");
b.Property<bool>("HasListUnsubscribe")
.HasColumnType("boolean");
b.Property<bool>("IsImportant")
.HasColumnType("boolean");
b.Property<bool>("IsInInbox")
.HasColumnType("boolean");
b.Property<bool>("IsReadLater")
.HasColumnType("boolean");
b.Property<bool>("IsStarred")
.HasColumnType("boolean");
b.Property<bool>("IsTrashed")
.HasColumnType("boolean");
b.Property<bool>("IsUnread")
.HasColumnType("boolean");
b.Property<string>("ListUnsubscribeRaw")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("ReceivedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<NpgsqlTsVector>("SearchVector")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("tsvector")
.HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true);
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("SentAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("SizeEstimateBytes")
.HasColumnType("bigint");
b.Property<string>("Snippet")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Subject")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<bool>("SupportsOneClickUnsubscribe")
.HasColumnType("boolean");
b.Property<Guid>("ThreadId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Embedding");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" });
b.HasIndex("SearchVector");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN");
b.HasIndex("SenderId");
b.HasIndex("ThreadId");
b.HasIndex("UserId", "Category");
b.HasIndex("UserId", "GmailMessageId")
.IsUnique();
b.HasIndex("UserId", "IsInInbox");
b.HasIndex("UserId", "IsUnread");
b.HasIndex("UserId", "SenderId");
b.HasIndex("UserId", "SentAtUtc");
b.ToTable("emails", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
{
b.Property<Guid>("EmailId")
.HasColumnType("uuid");
b.Property<Guid>("LabelId")
.HasColumnType("uuid");
b.HasKey("EmailId", "LabelId");
b.HasIndex("LabelId");
b.ToTable("email_labels", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.FeatureFlag", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<bool>("UserOverridable")
.HasColumnType("boolean");
b.HasKey("Key");
b.ToTable("feature_flags", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ColorHex")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("GmailLabelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "GmailLabelId")
.IsUnique();
b.ToTable("labels", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<bool>("IsBulkSender")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Name");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("UserId", "Name")
.IsUnique();
b.ToTable("domains", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("FirstMessageUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("GmailThreadId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("LastMessageUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("MessageCount")
.HasColumnType("integer");
b.Property<string>("Snippet")
.HasColumnType("text");
b.Property<string>("Subject")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "GmailThreadId")
.IsUnique();
b.ToTable("threads", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("character varying(320)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid>("DomainId")
.HasColumnType("uuid");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<bool>("HasUnsubscribe")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastReceivedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("TotalSizeBytes")
.HasColumnType("bigint");
b.Property<int>("UnreadCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Address");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Address"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Address"), new[] { "gin_trgm_ops" });
b.HasIndex("DisplayName");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("DisplayName"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("DisplayName"), new[] { "gin_trgm_ops" });
b.HasIndex("DomainId");
b.HasIndex("UserId", "Address")
.IsUnique();
b.HasIndex("UserId", "EmailCount");
b.ToTable("senders", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.SyncState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("ConsecutiveFailures")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastError")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("LastHistoryId")
.HasColumnType("text");
b.Property<DateTimeOffset?>("LastSuccessfulSyncUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("LastSyncType")
.HasColumnType("integer");
b.Property<int>("MessagesProcessed")
.HasColumnType("integer");
b.Property<string>("ResumePageToken")
.HasColumnType("text");
b.Property<DateTimeOffset?>("StartedUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("TotalMessagesEstimate")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("sync_states", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<double>("Confidence")
.HasColumnType("double precision");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("LastAttemptUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Method")
.HasColumnType("integer");
b.Property<string>("ResultMessage")
.HasColumnType("text");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("UnsubscribeTarget")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SenderId");
b.HasIndex("UserId", "SenderId")
.IsUnique();
b.ToTable("unsubscribe_items", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("AccessTokenExpiresAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<bool>("DigestEnabled")
.HasColumnType("boolean");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("character varying(320)");
b.Property<byte[]>("EncryptedRefreshToken")
.HasColumnType("bytea");
b.Property<string>("GoogleSubjectId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("LastDigestSentUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastLoginUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("PictureUrl")
.HasColumnType("text");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("GoogleSubjectId")
.IsUnique();
b.ToTable("users", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<bool>("AiOptIn")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("PreferencesJson")
.HasColumnType("text");
b.Property<string>("Theme")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("UserId");
b.ToTable("user_settings", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("H")
.HasColumnType("integer");
b.Property<string>("SettingsJson")
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<bool>("Visible")
.HasColumnType("boolean");
b.Property<int>("W")
.HasColumnType("integer");
b.Property<string>("WidgetKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("X")
.HasColumnType("integer");
b.Property<int>("Y")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("UserId", "WidgetKey")
.IsUnique();
b.ToTable("widget_layouts", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
.WithMany("Attachments")
.HasForeignKey("EmailId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Email");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
.WithMany("Emails")
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("InboxIntel.Domain.Entities.MailThread", "Thread")
.WithMany("Emails")
.HasForeignKey("ThreadId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("InboxIntel.Domain.Entities.User", null)
.WithMany("Emails")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Sender");
b.Navigation("Thread");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
.WithMany("EmailLabels")
.HasForeignKey("EmailId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("InboxIntel.Domain.Entities.Label", "Label")
.WithMany("EmailLabels")
.HasForeignKey("LabelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Email");
b.Navigation("Label");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
{
b.HasOne("InboxIntel.Domain.Entities.MailDomain", "Domain")
.WithMany("Senders")
.HasForeignKey("DomainId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Domain");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
.WithMany()
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Sender");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
{
b.HasOne("InboxIntel.Domain.Entities.User", "User")
.WithOne()
.HasForeignKey("InboxIntel.Domain.Entities.UserSetting", "UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
{
b.HasOne("InboxIntel.Domain.Entities.User", null)
.WithMany("WidgetLayouts")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
{
b.Navigation("Attachments");
b.Navigation("EmailLabels");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
{
b.Navigation("EmailLabels");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
{
b.Navigation("Senders");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
{
b.Navigation("Emails");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
{
b.Navigation("Emails");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
{
b.Navigation("Emails");
b.Navigation("WidgetLayouts");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace InboxIntel.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddIsReadLater : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsReadLater",
table: "emails",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsReadLater",
table: "emails");
}
}
}
@@ -147,9 +147,6 @@ namespace InboxIntel.Infrastructure.Migrations
b.Property<bool>("IsInInbox") b.Property<bool>("IsInInbox")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("IsReadLater")
.HasColumnType("boolean");
b.Property<bool>("IsStarred") b.Property<bool>("IsStarred")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -52,32 +52,8 @@ public class SearchService : ISearchService
q = q.Where(e => e.IsStarred == starred); q = q.Where(e => e.IsStarred == starred);
if (r.IsTrashed is { } trashed) if (r.IsTrashed is { } trashed)
q = q.Where(e => e.IsTrashed == trashed); q = q.Where(e => e.IsTrashed == trashed);
if (r.IsImportant is { } important)
q = q.Where(e => e.IsImportant == important);
if (r.IsReadLater is { } readLater)
q = q.Where(e => e.IsReadLater == readLater);
if (r.MinSizeBytes is { } minSize) if (r.MinSizeBytes is { } minSize)
q = q.Where(e => e.SizeEstimateBytes >= minSize); q = q.Where(e => e.SizeEstimateBytes >= minSize);
// Unlabelled: emails with (no) USER labels. System labels (INBOX/SENT/…) don't count,
// so Unlabelled means "not filed into any user-created label".
if (r.HasUserLabels is { } hasUserLabels)
{
if (hasUserLabels)
q = q.Where(e => e.EmailLabels.Any(el => el.Label!.Type == "user"));
else
q = q.Where(e => !e.EmailLabels.Any(el => el.Label!.Type == "user"));
}
// Archive excludes Sent/Spam/Draft/Chat/Trash by label absence, not just "not in inbox".
if (r.ExcludeGmailLabels is { Count: > 0 } exclude)
{
var excludeUpper = exclude.Select(x => x.ToUpperInvariant()).ToList();
var excludeIds = await _db.Labels
.Where(l => l.UserId == userId && excludeUpper.Contains(l.GmailLabelId.ToUpper()))
.Select(l => l.Id)
.ToListAsync(ct);
if (excludeIds.Count > 0)
q = q.Where(e => !e.EmailLabels.Any(el => excludeIds.Contains(el.LabelId)));
}
if (!string.IsNullOrWhiteSpace(r.Category) && Enum.TryParse<EmailCategory>(r.Category, true, out var cat)) if (!string.IsNullOrWhiteSpace(r.Category) && Enum.TryParse<EmailCategory>(r.Category, true, out var cat))
q = q.Where(e => e.Category == cat); q = q.Where(e => e.Category == cat);
if (!string.IsNullOrWhiteSpace(r.GmailLabel)) if (!string.IsNullOrWhiteSpace(r.GmailLabel))
@@ -228,34 +228,9 @@ public class SyncService : ISyncService
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
} }
// Per-sync-run cache of the user's GmailLabelId -> local Label.Id, so email/label linkage /// <summary>Resolves domain/sender/thread, then inserts the email and attachment metadata.</summary>
// costs no extra query per message. Populated lazily; labels are synced before messages.
private Dictionary<string, Guid>? _labelCache;
private Guid _labelCacheUserId;
private async Task<Dictionary<string, Guid>> GetLabelMapAsync(Guid userId, CancellationToken ct)
{
if (_labelCache is null || _labelCacheUserId != userId)
{
_labelCache = await _db.Labels
.Where(l => l.UserId == userId)
.ToDictionaryAsync(l => l.GmailLabelId, l => l.Id, ct);
_labelCacheUserId = userId;
}
return _labelCache;
}
/// <summary>Resolves domain/sender/thread, then upserts the email, its labels, and
/// attachment metadata. Idempotent: re-syncing a message replaces the prior row (and its
/// labels) rather than duplicating it.</summary>
private async Task UpsertMessageAsync(Guid userId, GmailMessageDetail d, CancellationToken ct) private async Task UpsertMessageAsync(Guid userId, GmailMessageDetail d, CancellationToken ct)
{ {
// True upsert: drop any existing copy first so labels/flags re-populate cleanly and
// an incrementally-changed message can't be duplicated. EmailLabels/Attachments cascade.
var prior = await _db.Emails.FirstOrDefaultAsync(
e => e.UserId == userId && e.GmailMessageId == d.GmailMessageId, ct);
if (prior is not null) _db.Emails.Remove(prior);
var sender = await ResolveSenderAsync(userId, d.FromAddress, d.FromDisplayName, ct); var sender = await ResolveSenderAsync(userId, d.FromAddress, d.FromDisplayName, ct);
var thread = await ResolveThreadAsync(userId, d.GmailThreadId, d.Subject, d.Snippet, d.SentAtUtc, ct); var thread = await ResolveThreadAsync(userId, d.GmailThreadId, d.Subject, d.Snippet, d.SentAtUtc, ct);
@@ -281,7 +256,6 @@ public class SyncService : ISyncService
IsInInbox = d.LabelIds.Contains("INBOX"), IsInInbox = d.LabelIds.Contains("INBOX"),
IsStarred = d.LabelIds.Contains("STARRED"), IsStarred = d.LabelIds.Contains("STARRED"),
IsImportant = d.LabelIds.Contains("IMPORTANT"), IsImportant = d.LabelIds.Contains("IMPORTANT"),
IsTrashed = d.LabelIds.Contains("TRASH"),
HasAttachments = d.HasAttachments, HasAttachments = d.HasAttachments,
HasListUnsubscribe = d.HasListUnsubscribe, HasListUnsubscribe = d.HasListUnsubscribe,
ListUnsubscribeRaw = Trunc(d.ListUnsubscribeRaw, 2048), ListUnsubscribeRaw = Trunc(d.ListUnsubscribeRaw, 2048),
@@ -290,14 +264,6 @@ public class SyncService : ISyncService
}; };
_db.Emails.Add(email); _db.Emails.Add(email);
// Link the email to its Gmail labels (system + user) so label-based folders — Sent,
// Spam, and "Unlabelled" (no user label) — resolve correctly. Previously no EmailLabel
// rows were ever created, so every label folder was empty and every email looked unlabelled.
var labelMap = await GetLabelMapAsync(userId, ct);
foreach (var gmailLabelId in d.LabelIds.Distinct())
if (labelMap.TryGetValue(gmailLabelId, out var localLabelId))
_db.Set<EmailLabel>().Add(new EmailLabel { EmailId = email.Id, LabelId = localLabelId });
foreach (var (fileName, mime, size, attId) in d.Attachments) foreach (var (fileName, mime, size, attId) in d.Attachments)
{ {
_db.Attachments.Add(new Attachment _db.Attachments.Add(new Attachment
@@ -1,115 +0,0 @@
using FluentAssertions;
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.DTOs;
using InboxIntel.Domain.Entities;
using InboxIntel.Infrastructure.Persistence;
using InboxIntel.Infrastructure.Search;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace InboxIntel.IntegrationTests;
/// <summary>
/// PHASE 2 (smart-category correctness): each reported folder bug — Archive leaking Sent,
/// Read Later / Pinned / Unlabelled returning everything, Old Mail duration — is fixed at the
/// filter source. These prove the SearchService filters that back the corrected folder mappings.
/// </summary>
public class CategoryFilterTests
{
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid UserId { get; set; }
public bool IsAuthenticated => UserId != Guid.Empty;
}
private static AppDbContext Db(string name, Guid uid) =>
new(new DbContextOptionsBuilder<AppDbContext>().UseInMemoryDatabase(name).Options,
new FakeCurrentUser { UserId = uid });
// A small fixture: an inbox email, a sent email (SENT label), one important, one read-later,
// and one filed under a user label.
private static async Task<Guid> SeedAsync(string name)
{
var uid = Guid.NewGuid();
using var db = Db(name, Guid.Empty);
var sender = new Sender { UserId = uid, Address = "s@x.x" };
db.Senders.Add(sender);
var sentLabel = new Label { UserId = uid, GmailLabelId = "SENT", Name = "Sent", Type = "system" };
var userLabel = new Label { UserId = uid, GmailLabelId = "Label_1", Name = "Projects", Type = "user" };
db.Labels.AddRange(sentLabel, userLabel);
Email E(string id, Action<Email> cfg)
{
var e = new Email { UserId = uid, GmailMessageId = id, Subject = id, Sender = sender, SentAtUtc = DateTimeOffset.UtcNow };
cfg(e);
db.Emails.Add(e);
return e;
}
var inbox = E("inbox", e => e.IsInInbox = true);
var sent = E("sent", e => e.IsInInbox = false); // archived-looking, but it's Sent
var important = E("important", e => e.IsImportant = true);
var later = E("later", e => e.IsReadLater = true);
var filed = E("filed", e => e.IsInInbox = false);
await db.SaveChangesAsync();
db.Set<EmailLabel>().AddRange(
new EmailLabel { EmailId = sent.Id, LabelId = sentLabel.Id },
new EmailLabel { EmailId = filed.Id, LabelId = userLabel.Id });
await db.SaveChangesAsync();
return uid;
}
private static async Task<List<string>> RunAsync(string name, Guid uid, SearchRequestDto req)
{
using var db = Db(name, uid);
var res = await new SearchService(db).SearchAsync(uid, req);
return res.Items.Select(i => i.GmailMessageId).OrderBy(x => x).ToList();
}
private static SearchRequestDto Base() => new(null, null, null, null, null, null, null, false, 1, 50);
[Fact]
public async Task Archive_excludes_sent_mail()
{
var name = nameof(Archive_excludes_sent_mail);
var uid = await SeedAsync(name);
// Archive: not-in-inbox, not-trashed, excluding SENT — must NOT contain "sent".
var items = await RunAsync(name, uid, Base() with
{
IsInInbox = false,
IsTrashed = false,
ExcludeGmailLabels = new[] { "SENT", "SPAM", "DRAFT" }
});
items.Should().NotContain("sent");
items.Should().Contain("filed"); // a genuinely archived, user-filed mail stays
}
[Fact]
public async Task Pinned_returns_only_important_not_everything()
{
var name = nameof(Pinned_returns_only_important_not_everything);
var uid = await SeedAsync(name);
var items = await RunAsync(name, uid, Base() with { IsImportant = true });
items.Should().Equal("important");
}
[Fact]
public async Task ReadLater_returns_only_flagged_not_everything()
{
var name = nameof(ReadLater_returns_only_flagged_not_everything);
var uid = await SeedAsync(name);
var items = await RunAsync(name, uid, Base() with { IsReadLater = true });
items.Should().Equal("later");
}
[Fact]
public async Task Unlabelled_excludes_user_labelled_mail()
{
var name = nameof(Unlabelled_excludes_user_labelled_mail);
var uid = await SeedAsync(name);
// "filed" has a user label → must be absent; everything else (no user label) present.
var items = await RunAsync(name, uid, Base() with { HasUserLabels = false });
items.Should().NotContain("filed");
items.Should().Contain("inbox");
}
}