feat: Smart Folders sidebar, category heatmap, sidebar counts API

- Replace plain activity heatmap with CategoryHeatmapWidget on dashboard
- Add collapsible Smart Folders sidebar with Favorites, Mailbox, Smart Folders sections
- Favorites: customisable pinned shortcuts (default: Inbox, Unread, Large, Old, Read Later); pin/unpin smart folders via buttons; persists to localStorage
- Mailbox section: 11 Gmail mailbox items with icons and live count badges
- Smart Folders section: 10 category folders sorted by count desc
- Live count badges via new GET /api/v1/analytics/sidebar-counts endpoint
- Backend: SidebarCountsDto, GetSidebarCountsAsync with label lookups for SENT/DRAFT/SPAM, size/age filters, EmailCategory mapping
- Sidebar collapses to icon-only; section states persist to localStorage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-06-30 19:40:54 +02:00
parent fe5920919f
commit ca02f40841
17 changed files with 2866 additions and 66 deletions
+2153
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -39,7 +39,11 @@ export const AnalyticsApi = {
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),
heatmap: () => api.get('/analytics/heatmap').then((r) => r.data),
attachments: () => api.get('/analytics/attachments').then((r) => r.data)
categoryHeatmap: () => api.get('/analytics/category-heatmap').then((r) => r.data),
attachments: () => api.get('/analytics/attachments').then((r) => r.data),
sidebarCounts: () => api.get('/analytics/sidebar-counts').then((r) => r.data),
// Large take to retrieve all senders for the volume-tiers page.
allSenders: () => api.get('/analytics/top-senders?take=5000').then((r) => r.data)
};
export const CleanupApi = {
+249 -9
View File
@@ -1,31 +1,194 @@
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { AuthApi, SyncApi } from '../api/client.js';
import { useEffect, useState, useCallback } from 'react';
import { AuthApi, SyncApi, AnalyticsApi } from '../api/client.js';
import Logo from './Logo.jsx';
import DevBanner from './DevBanner.jsx';
// ── Folder definitions ────────────────────────────────────────────────────────
const MAILBOX = [
{ slug: 'inbox', icon: '📥', label: 'Inbox', countKey: 'inbox' },
{ slug: 'allmail', icon: '📬', label: 'All Mail', countKey: 'allMail' },
{ slug: 'starred', icon: '⭐', label: 'Starred', countKey: 'starred' },
{ slug: 'sent', icon: '📤', label: 'Sent', countKey: 'sent' },
{ slug: 'drafts', icon: '✏️', label: 'Drafts', countKey: 'drafts' },
{ slug: 'archive', icon: '📦', label: 'Archive', countKey: null },
{ slug: 'spam', icon: '🚫', label: 'Spam', countKey: 'spam' },
{ slug: 'trash', icon: '🗑️', label: 'Trash', countKey: 'trash' },
{ slug: 'unlabeled', icon: '🏷️', label: 'Unlabeled', countKey: null },
{ slug: 'pinned', icon: '📌', label: 'Pinned', countKey: null },
{ slug: 'readlater', icon: '🔖', label: 'Read Later', countKey: null },
];
// Special items always available as favorites (not smart folders)
const FAV_SPECIALS = {
inbox: { slug: 'inbox', icon: '📥', label: 'Inbox', countKey: 'inbox', type: 'mailbox' },
unread: { slug: 'unread', icon: '🔵', label: 'Unread Mail', countKey: 'unread', type: 'filter' },
large: { slug: 'large', icon: '📎', label: 'Large Mail', countKey: 'large', type: 'filter' },
old: { slug: 'old', icon: '🕰️', label: 'Old Mail', countKey: 'old', type: 'filter' },
readlater: { slug: 'readlater', icon: '🔖', label: 'Read Later', countKey: null, type: 'filter' },
};
const DEFAULT_FAV_SLUGS = ['inbox', 'unread', 'large', 'old', 'readlater'];
const SMART_FOLDERS = [
{ slug: 'automated', icon: '🤖', label: 'Automated', countKey: 'automated' },
{ slug: 'noreply', icon: '🔇', label: 'No-Reply', countKey: 'noreply' },
{ slug: 'shopping', icon: '🛍️', label: 'Online Shopping', countKey: 'shopping' },
{ slug: 'gaming', icon: '🎮', label: 'Gaming', countKey: 'gaming' },
{ slug: 'finance', icon: '💳', label: 'Finance & Insurance', countKey: 'finance' },
{ slug: 'sales', icon: '🏷️', label: 'Seasonal Sales', countKey: 'sales' },
{ slug: 'ridesharing', icon: '🚗', label: 'Ride Sharing', countKey: 'ridesharing' },
{ slug: 'food', icon: '🍕', label: 'Food Delivery', countKey: 'food' },
{ slug: 'social', icon: '📱', label: 'Social Notifications',countKey: 'social' },
{ slug: 'wellness', icon: '🏃', label: 'Wellness & Sport', countKey: 'wellness' },
];
// ── Helpers ───────────────────────────────────────────────────────────────────
const fmtCount = (n) => {
if (!n) return null;
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
};
const LS_FAV = 'ii:fav-slugs';
const LS_OPEN = 'ii:sidebar-open';
const LS_SECTS = 'ii:sidebar-sections';
const loadFavs = () => {
try { return JSON.parse(localStorage.getItem(LS_FAV)) ?? DEFAULT_FAV_SLUGS; }
catch { return DEFAULT_FAV_SLUGS; }
};
const saveFavs = (v) => localStorage.setItem(LS_FAV, JSON.stringify(v));
const loadSections = () => {
try { return JSON.parse(localStorage.getItem(LS_SECTS)) ?? { fav: true, mailbox: true, smart: true }; }
catch { return { fav: true, mailbox: true, smart: true }; }
};
const saveSections = (v) => localStorage.setItem(LS_SECTS, JSON.stringify(v));
// ── Sub-components ────────────────────────────────────────────────────────────
function SectionHead({ label, open, onToggle, collapsed }) {
return (
<button className="section-head" onClick={onToggle} title={label}>
{!collapsed && <span className="section-title">{label}</span>}
<span className={`section-arrow${open ? '' : ' section-arrow--closed'}`}></span>
</button>
);
}
function FolderLink({ item, active, count, collapsed, extra }) {
return (
<Link
to={`/app/folder/${item.slug}`}
className={`folder-item${active ? ' folder-item--active' : ''}`}
title={item.label}
>
<span className="folder-icon">{item.icon}</span>
{!collapsed && (
<>
<span className="folder-label">{item.label}</span>
<span className="folder-spacer" />
{count && <span className="folder-badge">{count}</span>}
{extra}
</>
)}
</Link>
);
}
// ── Main Layout ───────────────────────────────────────────────────────────────
export default function Layout() {
const [user, setUser] = useState(null);
const [sidebarOpen, setSidebarOpen] = useState(() => {
try { return localStorage.getItem(LS_OPEN) !== 'false'; } catch { return true; }
});
const [sections, setSections] = useState(loadSections);
const [favSlugs, setFavSlugs] = useState(loadFavs);
const [counts, setCounts] = useState(null);
const loc = useLocation();
const navigate = useNavigate();
useEffect(() => { AuthApi.me().then(setUser).catch(() => {}); }, []);
useEffect(() => {
AuthApi.me().then(setUser).catch(() => {});
AnalyticsApi.sidebarCounts().then(setCounts).catch(() => {});
}, []);
const toggleSidebar = () => {
setSidebarOpen((o) => {
localStorage.setItem(LS_OPEN, String(!o));
return !o;
});
};
const toggleSection = (key) => {
setSections((s) => {
const next = { ...s, [key]: !s[key] };
saveSections(next);
return next;
});
};
const toggleFav = useCallback((slug) => {
setFavSlugs((prev) => {
const next = prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug];
saveFavs(next);
return next;
});
}, []);
const nav = [
{ to: '/app', label: 'Dashboard', end: true },
{ to: '/app/senders', label: 'Senders' },
{ to: '/app/cleanup', label: 'Cleanup' },
{ to: '/app/unsubscribe', label: 'Unsubscribe' }
];
const isActive = (n) => (n.end ? loc.pathname === n.to : loc.pathname.startsWith(n.to));
const isNavActive = (n) => (n.end ? loc.pathname === n.to : loc.pathname.startsWith(n.to));
const activeSlug = loc.pathname.startsWith('/app/folder/')
? loc.pathname.split('/app/folder/')[1]
: null;
const logout = async () => {
try { await AuthApi.logout(); } catch { /* ignore */ }
navigate('/');
};
const startSync = async () => {
try { await SyncApi.incremental(); } catch { /* ignore */ }
if (loc.pathname !== '/app') navigate('/app');
window.dispatchEvent(new Event('inboxintel:sync-started'));
};
// Count getters
const mailboxCount = (item) => fmtCount(item.countKey ? counts?.[item.countKey] : null);
const smartCount = (item) => fmtCount(counts?.smartFolders?.[item.slug]);
// Build favorites list: specials first (in default order), then pinned smart folders
const favItems = favSlugs.map((slug) => {
if (FAV_SPECIALS[slug]) return { ...FAV_SPECIALS[slug], source: 'special' };
const sf = SMART_FOLDERS.find((f) => f.slug === slug);
return sf ? { ...sf, source: 'smart' } : null;
}).filter(Boolean);
const favCount = (item) => {
if (item.source === 'smart') return smartCount(item);
return fmtCount(item.countKey ? counts?.[item.countKey] : null);
};
// Smart folders sorted by count desc
const sortedSmart = [...SMART_FOLDERS].sort((a, b) => {
const ca = counts?.smartFolders?.[a.slug] ?? 0;
const cb = counts?.smartFolders?.[b.slug] ?? 0;
return cb - ca;
});
const isPinnedSmart = (slug) => favSlugs.includes(slug);
return (
<div className="app">
<DevBanner />
@@ -33,17 +196,94 @@ export default function Layout() {
<Link to="/app" className="brand-link"><Logo size={28} withWordmark /></Link>
<nav>
{nav.map((n) => (
<Link key={n.to} to={n.to} className={isActive(n) ? 'active' : ''}>{n.label}</Link>
<Link key={n.to} to={n.to} className={isNavActive(n) ? 'active' : ''}>{n.label}</Link>
))}
</nav>
<div className="spacer" />
<button onClick={() => SyncApi.incremental()}>Sync now</button>
<button onClick={startSync}>Sync now</button>
<span className="user">{user?.email}</span>
<button className="ghost" onClick={logout}>Log out</button>
</header>
<main>
<Outlet />
</main>
<div className="app-body">
<aside className={`sidebar${sidebarOpen ? '' : ' sidebar--collapsed'}`}>
<div className="sidebar-header">
{sidebarOpen && <span className="sidebar-brand">Folders</span>}
<button className="sidebar-toggle ghost" onClick={toggleSidebar} title={sidebarOpen ? 'Collapse' : 'Expand'}>
{sidebarOpen ? '' : ''}
</button>
</div>
{/* ── Favorites ── */}
<SectionHead label="Favorites" open={sections.fav} onToggle={() => toggleSection('fav')} collapsed={!sidebarOpen} />
{sections.fav && (
<ul className="folder-list">
{favItems.map((item) => (
<li key={item.slug}>
<FolderLink
item={item}
active={activeSlug === item.slug}
count={favCount(item)}
collapsed={!sidebarOpen}
extra={item.source === 'smart' && sidebarOpen && (
<button
className="fav-pin fav-pin--remove"
title="Remove from Favorites"
onClick={(e) => { e.preventDefault(); toggleFav(item.slug); }}
></button>
)}
/>
</li>
))}
</ul>
)}
{/* ── Mailbox ── */}
<SectionHead label="Mailbox" open={sections.mailbox} onToggle={() => toggleSection('mailbox')} collapsed={!sidebarOpen} />
{sections.mailbox && (
<ul className="folder-list">
{MAILBOX.map((item) => (
<li key={item.slug}>
<FolderLink
item={item}
active={activeSlug === item.slug}
count={mailboxCount(item)}
collapsed={!sidebarOpen}
/>
</li>
))}
</ul>
)}
{/* ── Smart Folders ── */}
<SectionHead label="Smart Folders" open={sections.smart} onToggle={() => toggleSection('smart')} collapsed={!sidebarOpen} />
{sections.smart && (
<ul className="folder-list">
{sortedSmart.map((item) => (
<li key={item.slug}>
<FolderLink
item={item}
active={activeSlug === item.slug}
count={smartCount(item)}
collapsed={!sidebarOpen}
extra={sidebarOpen && (
<button
className={`fav-pin${isPinnedSmart(item.slug) ? ' fav-pin--remove' : ''}`}
title={isPinnedSmart(item.slug) ? 'Remove from Favorites' : 'Add to Favorites'}
onClick={(e) => { e.preventDefault(); toggleFav(item.slug); }}
>{isPinnedSmart(item.slug) ? '✕' : '⊕'}</button>
)}
/>
</li>
))}
</ul>
)}
</aside>
<main>
<Outlet />
</main>
</div>
</div>
);
}
+38
View File
@@ -1,4 +1,6 @@
import { useEffect, useState } from 'react';
import { Bar, Line, Doughnut } from 'react-chartjs-2';
import { AnalyticsApi } from '../api/client.js';
import {
Chart as ChartJS, CategoryScale, LinearScale, BarElement, PointElement,
LineElement, ArcElement, Tooltip, Legend
@@ -91,6 +93,42 @@ export function HeatmapWidget({ heatmap }) {
);
}
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export function CategoryHeatmapWidget() {
const [cells, setCells] = useState(null);
useEffect(() => { AnalyticsApi.categoryHeatmap().then(setCells).catch(() => setCells([])); }, []);
if (!cells) return <div className="widget"><div className="muted">Loading</div></div>;
if (cells.length === 0) return <div className="widget"><h3>Category Heatmap</h3><div className="muted">No data yet run a sync.</div></div>;
const categories = [...new Set(cells.map((c) => c.category))].sort();
const max = Math.max(1, ...cells.map((c) => c.count));
const grid = {};
cells.forEach((c) => { grid[`${c.category}-${c.dayOfWeek}`] = c.count; });
return (
<div className="widget">
<h3>Category Heatmap</h3>
<div className="cat-heatmap">
<div className="chm-row chm-head">
<span className="chm-label" />
{DOW.map((d) => <span key={d} className="chm-col">{d}</span>)}
</div>
{categories.map((cat) => (
<div className="chm-row" key={cat}>
<span className="chm-label" title={cat}>{cat}</span>
{DOW.map((_, dow) => {
const v = grid[`${cat}-${dow}`] || 0;
return <span key={dow} className="chm-cell" style={{ opacity: 0.12 + 0.88 * (v / max) }} title={`${cat} · ${DOW[dow]}${v}`}>{v || ''}</span>;
})}
</div>
))}
</div>
</div>
);
}
export function AttachmentsWidget({ attachments }) {
if (!attachments) return <Empty />;
const data = {
+2
View File
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Landing from './pages/Landing.jsx';
import Dashboard from './pages/Dashboard.jsx';
import Senders from './pages/Senders.jsx';
import Cleanup from './pages/Cleanup.jsx';
import Unsubscribe from './pages/Unsubscribe.jsx';
import Layout from './components/Layout.jsx';
@@ -18,6 +19,7 @@ ReactDOM.createRoot(document.getElementById('root')).render(
{/* Authenticated app */}
<Route path="/app" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="senders" element={<Senders />} />
<Route path="cleanup" element={<Cleanup />} />
<Route path="unsubscribe" element={<Unsubscribe />} />
</Route>
+21 -5
View File
@@ -6,7 +6,7 @@ import { AnalyticsApi, LayoutApi, ExportApi, SyncApi } from '../api/client.js';
import SyncSplash from '../components/SyncSplash.jsx';
import {
HealthWidget, StatCard, TopSendersWidget, VolumeWidget,
HeatmapWidget, AttachmentsWidget, StorageWidget
CategoryHeatmapWidget, AttachmentsWidget, StorageWidget
} from '../components/widgets.jsx';
// Default grid geometry; overridden by the user's saved layout.
@@ -17,8 +17,8 @@ const DEFAULT_LAYOUT = [
{ i: 'storage', x: 7, y: 0, w: 2, h: 2 },
{ i: 'top-senders', x: 3, y: 2, w: 3, h: 5 },
{ i: 'volume', x: 6, y: 2, w: 6, h: 4 },
{ i: 'heatmap', x: 0, y: 5, w: 6, h: 4 },
{ i: 'attachments', x: 6, y: 6, w: 4, h: 4 }
{ i: 'category-heatmap', x: 0, y: 5, w: 6, h: 5 },
{ i: 'attachments', x: 6, y: 6, w: 4, h: 4 },
];
const ALL_WIDGETS = DEFAULT_LAYOUT.map((l) => l.i);
@@ -36,7 +36,11 @@ export default function Dashboard() {
useEffect(() => {
LayoutApi.get().then((saved) => {
if (saved?.length) {
setLayout(saved.map((w) => ({ i: w.widgetKey, x: w.x, y: w.y, w: w.w, h: w.h })));
const savedLayout = saved.map((w) => ({ i: w.widgetKey, x: w.x, y: w.y, w: w.w, h: w.h }));
const savedKeys = new Set(savedLayout.map((l) => l.i));
// Append any newer default widgets the saved layout doesn't know about yet.
const merged = [...savedLayout, ...DEFAULT_LAYOUT.filter((d) => !savedKeys.has(d.i))];
setLayout(merged);
setHidden(saved.filter((w) => !w.visible).map((w) => w.widgetKey));
}
}).catch(() => {});
@@ -54,6 +58,13 @@ export default function Dashboard() {
}).catch(loadData);
}, [loadData]);
// Show the splash when a sync is triggered from the header "Sync now" button.
useEffect(() => {
const onStart = () => setSyncing(true);
window.addEventListener('inboxintel:sync-started', onStart);
return () => window.removeEventListener('inboxintel:sync-started', onStart);
}, []);
const onSyncDone = useCallback(() => {
setSyncing(false);
loadData();
@@ -83,7 +94,7 @@ export default function Dashboard() {
case 'storage': return <StorageWidget bytes={data?.storageEstimateBytes} />;
case 'top-senders': return <TopSendersWidget senders={data?.topSenders} />;
case 'volume': return <VolumeWidget volume={data?.volumeOverTime} />;
case 'heatmap': return <HeatmapWidget heatmap={data?.heatmap} />;
case 'category-heatmap': return <CategoryHeatmapWidget />;
case 'attachments': return <AttachmentsWidget attachments={data?.attachmentBreakdown} />;
default: return null;
}
@@ -112,6 +123,11 @@ export default function Dashboard() {
cols={12}
rowHeight={60}
width={1200}
isResizable
isDraggable
resizeHandles={['se']}
compactType="vertical"
margin={[12, 12]}
onLayoutChange={onLayoutChange}
draggableHandle=".widget h3, .widget .stat-label"
>
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useMemo, useState } from 'react';
import { AnalyticsApi } from '../api/client.js';
// Volume tiers, highest first.
const TIERS = [
{ key: '1000+', label: '1000+ emails', min: 1000, max: Infinity, accent: '#eb5757' },
{ key: '500-1000', label: '500 1000', min: 500, max: 999, accent: '#f2994a' },
{ key: '250-500', label: '250 500', min: 250, max: 499, accent: '#f2c94c' },
{ key: '100-250', label: '100 250', min: 100, max: 249, accent: '#56ccf2' },
{ key: '<100', label: 'Under 100', min: 0, max: 99, accent: '#6fcf97' }
];
const PER_TIER = 60; // cap visible blocks per tier to keep it light
export default function Senders() {
const [senders, setSenders] = useState(null);
useEffect(() => { AnalyticsApi.allSenders().then(setSenders).catch(() => setSenders([])); }, []);
const buckets = useMemo(() => {
const map = Object.fromEntries(TIERS.map((t) => [t.key, []]));
(senders || []).forEach((s) => {
const tier = TIERS.find((t) => s.emailCount >= t.min && s.emailCount <= t.max);
if (tier) map[tier.key].push(s);
});
Object.values(map).forEach((arr) => arr.sort((a, b) => b.emailCount - a.emailCount));
return map;
}, [senders]);
if (!senders) return <div className="page"><h2>Senders by Volume</h2><p className="muted">Loading</p></div>;
return (
<div className="page senders-page">
<h2>Senders by Volume</h2>
<p className="muted">Who fills your inbox, grouped by how many emails they've sent you.</p>
{TIERS.map((tier) => {
const list = buckets[tier.key];
if (!list.length) return null;
const shown = list.slice(0, PER_TIER);
return (
<section className="tier" key={tier.key}>
<div className="tier-head">
<span className="tier-dot" style={{ background: tier.accent }} />
<h3>{tier.label}</h3>
<span className="tier-count">{list.length} sender{list.length === 1 ? '' : 's'}</span>
</div>
<div className="sender-blocks">
{shown.map((s) => (
<div className="sender-block" key={s.senderId} style={{ borderTopColor: tier.accent }}>
<div className="sb-name" title={s.address}>{s.displayName || s.address}</div>
<div className="sb-domain">{s.domain}</div>
<div className="sb-stats">
<span className="sb-count">{s.emailCount.toLocaleString()}</span>
{s.unreadCount > 0 && <span className="sb-unread">{s.unreadCount} unread</span>}
{s.hasUnsubscribe && <span className="sb-unsub">unsub</span>}
</div>
</div>
))}
</div>
{list.length > PER_TIER && <div className="muted tier-more">+ {list.length - PER_TIER} more</div>}
</section>
);
})}
{senders.length === 0 && <p className="muted">No senders yet run a sync first.</p>}
</div>
);
}
+104 -1
View File
@@ -19,7 +19,73 @@ body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-
.spacer { flex: 1; }
.user { color: var(--muted); font-size: 13px; }
main { padding: 20px; }
/* ── App body (sidebar + main) ── */
.app-body { display: flex; min-height: calc(100vh - 53px); }
.sidebar {
width: 216px; flex-shrink: 0;
background: var(--panel); border-right: 1px solid #2c3550;
display: flex; flex-direction: column; overflow-y: auto;
transition: width 0.2s ease;
}
.sidebar--collapsed { width: 48px; }
.sidebar-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 8px 10px;
border-bottom: 1px solid #2c3550;
min-height: 42px; flex-shrink: 0;
}
.sidebar-brand { font-size: 12px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); padding-left: 6px; }
.sidebar-toggle { padding: 2px 6px; font-size: 17px; line-height: 1; color: var(--muted); flex-shrink: 0; }
.sidebar-toggle:hover { color: var(--text); }
/* Section headings */
.section-head {
display: flex; align-items: center; justify-content: space-between;
width: 100%; background: none; border: none; border-bottom: 1px solid #2c3550;
padding: 7px 10px 7px 12px; cursor: pointer;
color: var(--muted); font-size: 10px; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase;
}
.section-head:hover { color: var(--text); }
.section-title { flex: 1; text-align: left; white-space: nowrap; overflow: hidden; }
.section-arrow { font-size: 13px; transform: rotate(90deg); display: inline-block; transition: transform 0.15s; }
.section-arrow--closed { transform: rotate(0deg); }
/* Folder items */
.folder-list { list-style: none; margin: 0; padding: 2px 0; }
.folder-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px 6px 12px;
color: var(--muted); text-decoration: none; font-size: 13px;
border-left: 3px solid transparent;
white-space: nowrap; overflow: hidden;
transition: background 0.1s, color 0.1s;
}
.folder-item:hover { background: var(--panel-2); color: var(--text); }
.folder-item:hover .fav-pin { opacity: 1; }
.folder-item--active { border-left-color: var(--accent); color: var(--text); background: var(--panel-2); }
.folder-icon { font-size: 14px; flex-shrink: 0; }
.folder-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.folder-spacer { flex: 1; }
.folder-badge {
font-size: 10px; font-weight: 600; color: var(--muted);
background: var(--panel-2); border-radius: 8px; padding: 1px 5px;
flex-shrink: 0;
}
.folder-item--active .folder-badge { background: #2c3550; color: var(--accent); }
/* Favorite pin/unpin button */
.fav-pin {
background: none; border: none; padding: 0 2px; cursor: pointer;
font-size: 12px; color: var(--muted); opacity: 0;
transition: opacity 0.1s, color 0.1s; flex-shrink: 0; line-height: 1;
}
.fav-pin:hover { color: var(--accent); }
.fav-pin--remove { opacity: 1; color: var(--danger); }
.fav-pin--remove:hover { color: #ff7070; }
main { padding: 20px; flex: 1; min-width: 0; }
button, .btn { background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; text-decoration: none; font-size: 14px; }
button.danger, .btn.danger { background: var(--danger); }
@@ -54,6 +120,43 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
.hm-day { width: 30px; font-size: 10px; color: var(--muted); }
.hm-cell { width: 10px; height: 10px; background: var(--accent); border-radius: 2px; }
/* Category heatmap */
.cat-heatmap { display: flex; flex-direction: column; gap: 3px; overflow: auto; }
.chm-row { display: grid; grid-template-columns: 92px repeat(7, 1fr); gap: 3px; align-items: stretch; }
.chm-head .chm-col { font-size: 10px; color: var(--muted); text-align: center; }
.chm-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; }
/* react-grid-layout resize handle — make it clearly visible on the dark theme */
.react-resizable-handle {
opacity: 0; transition: opacity 0.15s;
}
.grid-item:hover .react-resizable-handle { opacity: 1; }
.react-resizable-handle::after {
content: ''; position: absolute; right: 4px; bottom: 4px;
width: 9px; height: 9px;
border-right: 2px solid var(--accent); border-bottom: 2px solid var(--accent);
border-bottom-right-radius: 2px;
}
.react-grid-item.react-grid-placeholder { background: var(--accent); opacity: 0.25; border-radius: 10px; }
/* Senders by volume */
.senders-page { max-width: 1100px; }
.tier { margin-top: 22px; }
.tier-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.tier-head h3 { margin: 0; font-size: 16px; }
.tier-dot { width: 12px; height: 12px; border-radius: 50%; }
.tier-count { color: var(--muted); font-size: 12px; }
.sender-blocks { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
.sender-block { background: var(--panel); border: 1px solid #2c3550; border-top: 3px solid var(--accent); border-radius: 8px; padding: 10px 12px; }
.sb-name { font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sb-domain { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 6px; }
.sb-stats { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.sb-count { font-size: 20px; font-weight: 700; }
.sb-unread { font-size: 11px; color: var(--accent); }
.sb-unsub { font-size: 10px; color: var(--muted); border: 1px solid #36405c; border-radius: 4px; padding: 0 4px; }
.tier-more { margin-top: 8px; }
.page { max-width: 900px; }
.form-row { display: flex; gap: 10px; margin: 14px 0; }
.form-row input { flex: 1; }
@@ -25,6 +25,12 @@ public class AnalyticsController : ApiControllerBase
[HttpGet("heatmap")]
public async Task<IActionResult> Heatmap(CancellationToken ct) => Ok(await _analytics.GetHeatmapAsync(UserId, ct));
[HttpGet("category-heatmap")]
public async Task<IActionResult> CategoryHeatmap(CancellationToken ct) => Ok(await _analytics.GetCategoryHeatmapAsync(UserId, ct));
[HttpGet("attachments")]
public async Task<IActionResult> Attachments(CancellationToken ct) => Ok(await _analytics.GetAttachmentBreakdownAsync(UserId, ct));
[HttpGet("sidebar-counts")]
public async Task<IActionResult> SidebarCounts(CancellationToken ct) => Ok(await _analytics.GetSidebarCountsAsync(UserId, ct));
}
+8
View File
@@ -71,6 +71,14 @@ builder.Services.AddAuthentication(options =>
options.SaveTokens = true;
foreach (var scope in google.Scopes) options.Scope.Add(scope);
options.Events.OnCreatingTicket = GoogleAuthEvents.OnCreatingTicketAsync;
// Force the consent screen so Google ALWAYS returns a refresh token.
// Without this, Google omits the refresh token on re-authorisation,
// leaving offline Gmail sync with no usable credential.
options.Events.OnRedirectToAuthorizationEndpoint = context =>
{
context.Response.Redirect(context.RedirectUri + "&prompt=consent");
return Task.CompletedTask;
};
});
builder.Services.AddAuthorization();
+2 -2
View File
@@ -23,8 +23,8 @@
"DevMode": false
},
"GmailSync": {
"PageSize": 100,
"MaxParallelism": 4,
"PageSize": 500,
"MaxParallelism": 8,
"MaxRetries": 5,
"BackoffBaseMs": 500,
"DailySyncHourUtc": 3,
@@ -23,7 +23,9 @@ public interface IAnalyticsService
Task<IReadOnlyList<SenderStatDto>> GetTopSendersAsync(Guid userId, int take = 20, CancellationToken ct = default);
Task<IReadOnlyList<TimeSeriesPointDto>> GetVolumeOverTimeAsync(Guid userId, int days = 90, CancellationToken ct = default);
Task<IReadOnlyList<HeatmapCellDto>> GetHeatmapAsync(Guid userId, CancellationToken ct = default);
Task<IReadOnlyList<CategoryHeatmapCellDto>> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default);
Task<IReadOnlyList<AttachmentBreakdownDto>> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default);
Task<SidebarCountsDto> GetSidebarCountsAsync(Guid userId, CancellationToken ct = default);
Task RefreshAggregatesAsync(Guid userId, CancellationToken ct = default);
}
@@ -14,6 +14,23 @@ public record TimeSeriesPointDto(DateOnly Day, int Count);
public record HeatmapCellDto(int DayOfWeek, int Hour, int Count);
/// <summary>Email counts per category per day-of-week, for the category heatmap.</summary>
public record CategoryHeatmapCellDto(string Category, int DayOfWeek, int Count);
/// <summary>Counts for the sidebar: mailbox labels, special filters, and smart folders.</summary>
public record SidebarCountsDto(
int Inbox,
int AllMail,
int Unread,
int Starred,
int Sent,
int Drafts,
int Trash,
int Spam,
int Large,
int Old,
IReadOnlyDictionary<string, int> SmartFolders);
public record AttachmentBreakdownDto(string MimeBucket, long TotalBytes, int Count);
public record DashboardSummaryDto(
@@ -88,6 +88,19 @@ public class AnalyticsService : IAnalyticsService
.ToList();
}
public async Task<IReadOnlyList<CategoryHeatmapCellDto>> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default)
{
var raw = await _db.Emails
.Where(e => e.UserId == userId)
.Select(e => new { e.SentAtUtc, e.Category })
.ToListAsync(ct);
return raw
.GroupBy(x => new { x.Category, Dow = (int)x.SentAtUtc.DayOfWeek })
.Select(g => new CategoryHeatmapCellDto(g.Key.Category.ToString(), g.Key.Dow, g.Count()))
.ToList();
}
public async Task<IReadOnlyList<AttachmentBreakdownDto>> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default)
{
var raw = await _db.Attachments
@@ -115,6 +128,60 @@ public class AnalyticsService : IAnalyticsService
_ => "other"
};
public async Task<SidebarCountsDto> GetSidebarCountsAsync(Guid userId, CancellationToken ct = default)
{
var emails = _db.Emails.Where(e => e.UserId == userId);
var allMail = await emails.CountAsync(ct);
var inbox = await emails.CountAsync(e => e.IsInInbox, ct);
var unread = await emails.CountAsync(e => e.IsUnread, ct);
var starred = await emails.CountAsync(e => e.IsStarred, ct);
var trash = await emails.CountAsync(e => e.IsTrashed, ct);
var large = await emails.CountAsync(e => e.SizeEstimateBytes > 5_000_000, ct);
var cutoff = DateTimeOffset.UtcNow.AddYears(-1);
var old = await emails.CountAsync(e => e.SentAtUtc < cutoff, ct);
// Label-backed counts (SENT / DRAFT / SPAM are system Gmail labels)
async Task<int> LabelCount(string gmailId)
{
var labelId = await _db.Labels
.Where(l => l.UserId == userId && l.GmailLabelId == gmailId)
.Select(l => (Guid?)l.Id)
.FirstOrDefaultAsync(ct);
return labelId.HasValue
? await _db.EmailLabels.CountAsync(el => el.LabelId == labelId.Value, ct)
: 0;
}
var sent = await LabelCount("SENT");
var drafts = await LabelCount("DRAFT");
var spam = await LabelCount("SPAM");
// Category → smart-folder slug mapping
var catCounts = await emails
.GroupBy(e => e.Category)
.Select(g => new { Cat = g.Key, N = g.Count() })
.ToListAsync(ct);
int Cat(EmailCategory c) => catCounts.FirstOrDefault(x => x.Cat == c)?.N ?? 0;
var smartFolders = new Dictionary<string, int>
{
["automated"] = Cat(EmailCategory.Notification),
["finance"] = Cat(EmailCategory.Finance),
["social"] = Cat(EmailCategory.Social),
["shopping"] = Cat(EmailCategory.Promotional),
["noreply"] = 0,
["gaming"] = 0,
["sales"] = 0,
["ridesharing"] = 0,
["food"] = 0,
["wellness"] = 0,
};
return new SidebarCountsDto(inbox, allMail, unread, starred, sent, drafts, trash, spam, large, old, smartFolders);
}
public async Task RefreshAggregatesAsync(Guid userId, CancellationToken ct = default)
{
var since = DateTimeOffset.UtcNow.AddDays(-365);
@@ -19,8 +19,10 @@ public class GoogleOAuthOptions
public class GmailSyncOptions
{
public const string SectionName = "GmailSync";
public int PageSize { get; set; } = 100;
public int MaxParallelism { get; set; } = 4;
/// <summary>Message-id list page size (Gmail max is 500).</summary>
public int PageSize { get; set; } = 500;
/// <summary>Concurrent Gmail message fetches during sync (kept well under quota).</summary>
public int MaxParallelism { get; set; } = 8;
public int MaxRetries { get; set; } = 5;
/// <summary>Base delay in ms for exponential backoff.</summary>
public int BackoffBaseMs { get; set; } = 500;
@@ -24,6 +24,12 @@ public class GmailApiService : IGmailService
private readonly ILogger<GmailApiService> _logger;
private readonly ResiliencePipeline _pipeline;
// Cache the authenticated client per user so parallel message fetches don't
// each rebuild it (which would hit the shared DbContext concurrently).
private readonly SemaphoreSlim _clientLock = new(1, 1);
private Google.Apis.Gmail.v1.GmailService? _cachedClient;
private Guid _cachedUserId;
public GmailApiService(GmailClientFactory factory, IOptions<GmailSyncOptions> options, ILogger<GmailApiService> logger)
{
_factory = factory;
@@ -56,9 +62,26 @@ public class GmailApiService : IGmailService
or HttpStatusCode.ServiceUnavailable
or HttpStatusCode.GatewayTimeout;
/// <summary>Builds the Gmail client once per user and reuses it (thread-safe).</summary>
private async Task<Google.Apis.Gmail.v1.GmailService> GetClientAsync(Guid userId, CancellationToken ct)
{
if (_cachedClient is not null && _cachedUserId == userId) return _cachedClient;
await _clientLock.WaitAsync(ct);
try
{
if (_cachedClient is null || _cachedUserId != userId)
{
_cachedClient = await _factory.CreateAsync(userId, ct);
_cachedUserId = userId;
}
return _cachedClient;
}
finally { _clientLock.Release(); }
}
public async Task<string> GetProfileHistoryIdAsync(Guid userId, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var client = await GetClientAsync(userId, ct);
var profile = await _pipeline.ExecuteAsync(async token =>
await client.Users.GetProfile("me").ExecuteAsync(token), ct);
return profile?.HistoryId?.ToString() ?? throw new InvalidOperationException("Gmail profile returned no historyId.");
@@ -66,7 +89,7 @@ public class GmailApiService : IGmailService
public async Task<GmailMessagePage> ListMessageIdsAsync(Guid userId, string? pageToken, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var client = await GetClientAsync(userId, ct);
var page = await _pipeline.ExecuteAsync(async token =>
{
var req = client.Users.Messages.List("me");
@@ -82,7 +105,7 @@ public class GmailApiService : IGmailService
public async Task<GmailMessageDetail> GetMessageAsync(Guid userId, string gmailMessageId, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var client = await GetClientAsync(userId, ct);
var msg = await _pipeline.ExecuteAsync(async token =>
{
var req = client.Users.Messages.Get("me", gmailMessageId);
@@ -96,7 +119,7 @@ public class GmailApiService : IGmailService
public async Task<GmailHistoryPage> ListHistoryAsync(Guid userId, string startHistoryId, string? pageToken, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var client = await GetClientAsync(userId, ct);
var history = await _pipeline.ExecuteAsync(async token =>
{
var req = client.Users.History.List("me");
@@ -117,7 +140,7 @@ public class GmailApiService : IGmailService
public async Task<IReadOnlyList<DomainLabel>> ListLabelsAsync(Guid userId, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var client = await GetClientAsync(userId, ct);
var resp = await _pipeline.ExecuteAsync(async token =>
await client.Users.Labels.List("me").ExecuteAsync(token), ct);
@@ -132,7 +155,7 @@ public class GmailApiService : IGmailService
public async Task BatchModifyAsync(Guid userId, IEnumerable<string> messageIds, IEnumerable<string> addLabelIds, IEnumerable<string> removeLabelIds, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var client = await GetClientAsync(userId, ct);
var body = new BatchModifyMessagesRequest
{
Ids = messageIds.ToList(),
@@ -148,7 +171,7 @@ public class GmailApiService : IGmailService
public async Task BatchTrashAsync(Guid userId, IEnumerable<string> messageIds, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var client = await GetClientAsync(userId, ct);
foreach (var id in messageIds)
{
await _pipeline.ExecuteAsync(async token =>
@@ -161,7 +184,7 @@ public class GmailApiService : IGmailService
public async Task BatchDeleteAsync(Guid userId, IEnumerable<string> messageIds, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var client = await GetClientAsync(userId, ct);
var body = new BatchDeleteMessagesRequest { Ids = messageIds.ToList() };
await _pipeline.ExecuteAsync(async token =>
{
@@ -54,9 +54,11 @@ public class SyncService : ISyncService
public async Task<SyncProgressDto> GetProgressAsync(Guid userId, CancellationToken ct = default)
{
var s = await GetOrCreateStateAsync(userId, ct);
var total = _options.MaxMessages > 0
? Math.Min(s.TotalMessagesEstimate == 0 ? _options.MaxMessages : s.TotalMessagesEstimate, _options.MaxMessages)
: s.TotalMessagesEstimate;
// Total is unknown (0 -> indeterminate bar) until Gmail returns the real
// count; then it's the mailbox total, capped by MaxMessages in dev.
var total = s.TotalMessagesEstimate;
if (_options.MaxMessages > 0 && total > 0)
total = Math.Min(total, _options.MaxMessages);
return new SyncProgressDto(
s.Status.ToString(), s.MessagesProcessed, total,
s.Status == SyncStatus.Running, s.LastSuccessfulSyncUtc, s.LastError);
@@ -77,34 +79,48 @@ public class SyncService : ISyncService
// Dev cap: stop after MaxMessages (most recent first). 0 = unlimited.
var maxMessages = _options.MaxMessages;
var capReached = false;
string? pageToken = state.ResumePageToken; // resume support
// Phase 1 — enumerate message ids (cheap, ids only) to get an ACCURATE
// total. Gmail's resultSizeEstimate is unreliable, so we count instead.
var ids = new List<string>();
string? listToken = null;
do
{
var page = await _gmail.ListMessageIdsAsync(userId, pageToken, ct);
foreach (var messageId in page.MessageIds)
var page = await _gmail.ListMessageIdsAsync(userId, listToken, ct);
foreach (var id in page.MessageIds)
{
ids.Add(id);
if (maxMessages > 0 && ids.Count >= maxMessages) break;
}
listToken = page.NextPageToken;
}
while (listToken is not null && (maxMessages == 0 || ids.Count < maxMessages) && !ct.IsCancellationRequested);
state.TotalMessagesEstimate = ids.Count; // the true target (capped in dev)
await _db.SaveChangesAsync(ct);
// Phase 2 — fetch bodies in chunks, in parallel, skipping ones we already
// have. The DbContext is not thread-safe, so upserts run sequentially.
const int chunkSize = 100;
for (var i = 0; i < ids.Count && !ct.IsCancellationRequested; i += chunkSize)
{
var chunk = ids.GetRange(i, Math.Min(chunkSize, ids.Count - i));
var have = (await _db.Emails
.Where(e => e.UserId == userId && chunk.Contains(e.GmailMessageId))
.Select(e => e.GmailMessageId).ToListAsync(ct)).ToHashSet();
state.MessagesProcessed += have.Count; // already-synced count toward progress
var toFetch = chunk.Where(id => !have.Contains(id)).ToList();
var details = await FetchDetailsParallelAsync(userId, toFetch, ct);
foreach (var detail in details)
{
if (maxMessages > 0 && state.MessagesProcessed >= maxMessages)
{
capReached = true;
break;
}
if (await _db.Emails.AnyAsync(e => e.UserId == userId && e.GmailMessageId == messageId, ct))
continue;
var detail = await _gmail.GetMessageAsync(userId, messageId, ct);
await UpsertMessageAsync(userId, detail, ct);
state.MessagesProcessed++;
if (state.MessagesProcessed % 25 == 0)
await _db.SaveChangesAsync(ct); // smooth progress for the splash
}
pageToken = page.NextPageToken;
state.ResumePageToken = pageToken; // checkpoint
state.TotalMessagesEstimate = maxMessages > 0
? Math.Min(page.ResultSizeEstimate, maxMessages)
: page.ResultSizeEstimate;
await _db.SaveChangesAsync(ct);
}
while (pageToken is not null && !capReached && !ct.IsCancellationRequested);
state.LastHistoryId = await _gmail.GetProfileHistoryIdAsync(userId, ct);
state.ResumePageToken = null;
@@ -175,9 +191,14 @@ public class SyncService : ISyncService
private async Task MarkFailedAsync(SyncState state, Exception ex, CancellationToken ct)
{
_logger.LogError(ex, "Sync failed for user {UserId}", state.UserId);
state.Status = SyncStatus.Failed;
state.ConsecutiveFailures++;
state.LastError = ex.Message;
// Discard the failed batch's pending changes; otherwise saving the
// failure status re-attempts the same bad inserts and throws again.
_db.ChangeTracker.Clear();
var fresh = await _db.SyncStates.FirstOrDefaultAsync(s => s.UserId == state.UserId, ct);
if (fresh is null) return;
fresh.Status = SyncStatus.Failed;
fresh.ConsecutiveFailures++;
fresh.LastError = ex.Message;
await _db.SaveChangesAsync(ct);
}
@@ -217,9 +238,9 @@ public class SyncService : ISyncService
GmailMessageId = d.GmailMessageId,
ThreadId = thread.Id,
SenderId = sender.Id,
Subject = d.Subject,
Snippet = d.Snippet,
BodyText = d.BodyText,
Subject = Trunc(d.Subject, 1024),
Snippet = Trunc(d.Snippet, 2048),
BodyText = d.BodyText, // unlimited (text column)
SentAtUtc = d.SentAtUtc,
ReceivedAtUtc = d.SentAtUtc,
SizeEstimateBytes = d.SizeEstimateBytes,
@@ -229,7 +250,7 @@ public class SyncService : ISyncService
IsImportant = d.LabelIds.Contains("IMPORTANT"),
HasAttachments = d.HasAttachments,
HasListUnsubscribe = d.HasListUnsubscribe,
ListUnsubscribeRaw = d.ListUnsubscribeRaw,
ListUnsubscribeRaw = Trunc(d.ListUnsubscribeRaw, 2048),
SupportsOneClickUnsubscribe = d.SupportsOneClickUnsubscribe,
Category = HeuristicClassifier.Classify(d)
};
@@ -239,8 +260,8 @@ public class SyncService : ISyncService
{
_db.Attachments.Add(new Attachment
{
UserId = userId, EmailId = email.Id, FileName = fileName,
MimeType = mime, SizeBytes = size, GmailAttachmentId = attId
UserId = userId, EmailId = email.Id, FileName = Trunc(fileName, 512) ?? string.Empty,
MimeType = Trunc(mime, 255), SizeBytes = size, GmailAttachmentId = attId
});
}
@@ -255,13 +276,37 @@ public class SyncService : ISyncService
thread.LastMessageUtc = d.SentAtUtc;
}
/// <summary>
/// Fetches message bodies concurrently (bounded by MaxParallelism) to speed
/// up the sync. Results only — the caller persists them sequentially.
/// </summary>
private async Task<List<GmailMessageDetail>> FetchDetailsParallelAsync(Guid userId, List<string> ids, CancellationToken ct)
{
if (ids.Count == 0) return new List<GmailMessageDetail>();
using var sem = new SemaphoreSlim(Math.Max(1, _options.MaxParallelism));
var tasks = ids.Select(async id =>
{
await sem.WaitAsync(ct);
try { return await _gmail.GetMessageAsync(userId, id, ct); }
finally { sem.Release(); }
});
var results = await Task.WhenAll(tasks);
return results.ToList();
}
private async Task<Sender> ResolveSenderAsync(Guid userId, string address, string? displayName, CancellationToken ct)
{
var sender = await _db.Senders.FirstOrDefaultAsync(s => s.UserId == userId && s.Address == address, ct);
// Check the in-memory tracker first so senders/domains added earlier in
// this batch (not yet saved) are reused instead of duplicated.
var sender = _db.Senders.Local.FirstOrDefault(s => s.UserId == userId && s.Address == address)
?? await _db.Senders.FirstOrDefaultAsync(s => s.UserId == userId && s.Address == address, ct);
if (sender is not null) return sender;
var domainName = address.Contains('@') ? address[(address.IndexOf('@') + 1)..] : "unknown";
var domain = await _db.Domains.FirstOrDefaultAsync(x => x.UserId == userId && x.Name == domainName, ct);
address = Trunc(address, 320)!;
var rawDomain = address.Contains('@') ? address[(address.IndexOf('@') + 1)..] : "unknown";
var domainName = Trunc(rawDomain, 255)!;
var domain = _db.Domains.Local.FirstOrDefault(x => x.UserId == userId && x.Name == domainName)
?? await _db.Domains.FirstOrDefaultAsync(x => x.UserId == userId && x.Name == domainName, ct);
if (domain is null)
{
domain = new MailDomain { UserId = userId, Name = domainName };
@@ -269,19 +314,24 @@ public class SyncService : ISyncService
}
domain.EmailCount++;
sender = new Sender { UserId = userId, Address = address, DisplayName = displayName, Domain = domain, DomainId = domain.Id };
sender = new Sender { UserId = userId, Address = address, DisplayName = Trunc(displayName, 255), Domain = domain, DomainId = domain.Id };
_db.Senders.Add(sender);
return sender;
}
/// <summary>Truncates a string to a column's max length so no email can overflow it.</summary>
private static string? Trunc(string? value, int max)
=> value is null ? null : value.Length <= max ? value : value[..max];
private async Task<MailThread> ResolveThreadAsync(Guid userId, string gmailThreadId, string? subject, string? snippet, DateTimeOffset sentAt, CancellationToken ct)
{
var thread = await _db.Threads.FirstOrDefaultAsync(t => t.UserId == userId && t.GmailThreadId == gmailThreadId, ct);
var thread = _db.Threads.Local.FirstOrDefault(t => t.UserId == userId && t.GmailThreadId == gmailThreadId)
?? await _db.Threads.FirstOrDefaultAsync(t => t.UserId == userId && t.GmailThreadId == gmailThreadId, ct);
if (thread is not null) return thread;
thread = new MailThread
{
UserId = userId, GmailThreadId = gmailThreadId, Subject = subject,
Snippet = snippet, FirstMessageUtc = sentAt
UserId = userId, GmailThreadId = gmailThreadId, Subject = Trunc(subject, 1024),
Snippet = Trunc(snippet, 2048), FirstMessageUtc = sentAt
};
_db.Threads.Add(thread);
return thread;