Compare commits
2 Commits
dcb939e4f2
...
ca02f40841
| Author | SHA1 | Date | |
|---|---|---|---|
| ca02f40841 | |||
| fe5920919f |
@@ -10,3 +10,8 @@ AI_MODE=Disabled
|
|||||||
|
|
||||||
# Origin the API allows for CORS (the frontend container).
|
# Origin the API allows for CORS (the frontend container).
|
||||||
FRONTEND_ORIGIN=http://localhost:8081
|
FRONTEND_ORIGIN=http://localhost:8081
|
||||||
|
|
||||||
|
# Dev mode: shows the "test/dev" banner and caps the initial sync.
|
||||||
|
# Set DEV_MODE=true and MAX_MESSAGES=1000 to test against a large mailbox.
|
||||||
|
DEV_MODE=false
|
||||||
|
MAX_MESSAGES=0
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ services:
|
|||||||
GoogleOAuth__ClientId: ${GOOGLE_CLIENT_ID:-}
|
GoogleOAuth__ClientId: ${GOOGLE_CLIENT_ID:-}
|
||||||
GoogleOAuth__ClientSecret: ${GOOGLE_CLIENT_SECRET:-}
|
GoogleOAuth__ClientSecret: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
Ai__Mode: ${AI_MODE:-Disabled}
|
Ai__Mode: ${AI_MODE:-Disabled}
|
||||||
|
# Dev mode shows the dev banner and caps the initial sync. Set DEV_MODE=true
|
||||||
|
# and MAX_MESSAGES=1000 in deploy/.env to exercise it in this Docker setup.
|
||||||
|
App__DevMode: ${DEV_MODE:-false}
|
||||||
|
GmailSync__MaxMessages: ${MAX_MESSAGES:-0}
|
||||||
Cors__Origins__0: ${FRONTEND_ORIGIN:-http://localhost:8081}
|
Cors__Origins__0: ${FRONTEND_ORIGIN:-http://localhost:8081}
|
||||||
volumes:
|
volumes:
|
||||||
- keys:/keys
|
- keys:/keys
|
||||||
|
|||||||
+2
-1
@@ -2,8 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>InboxIntel</title>
|
<title>InboxIntel — Gmail analytics & cleanup</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+4
-4
@@ -12,10 +12,10 @@ server {
|
|||||||
# Proxy API + auth calls to the backend container.
|
# Proxy API + auth calls to the backend container.
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api:8080;
|
proxy_pass http://api:8080;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
proxy_set_header Cookie $http_cookie;
|
proxy_set_header Cookie $http_cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,10 +23,10 @@ server {
|
|||||||
# reach the backend so the cookie session is established same-origin.
|
# reach the backend so the cookie session is established same-origin.
|
||||||
location ~ ^/(signin-google|signout-google) {
|
location ~ ^/(signin-google|signout-google) {
|
||||||
proxy_pass http://api:8080;
|
proxy_pass http://api:8080;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
proxy_set_header Cookie $http_cookie;
|
proxy_set_header Cookie $http_cookie;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2153
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="48" y2="48" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#4f8cff"/><stop offset="1" stop-color="#3b6fe0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="2" y="2" width="44" height="44" rx="12" fill="url(#g)"/>
|
||||||
|
<path d="M12 21v11a3 3 0 0 0 3 3h18a3 3 0 0 0 3-3V21l-4.2 0a2 2 0 0 0-1.9 1.4l-.5 1.5a2 2 0 0 1-1.9 1.4h-6.2a2 2 0 0 1-1.9-1.4l-.5-1.5A2 2 0 0 0 16.2 21H12Z" fill="#fff" fill-opacity="0.96"/>
|
||||||
|
<path d="M16 21l1.6-7.2A2 2 0 0 1 19.6 12h8.8a2 2 0 0 1 1.95 1.55L32 21" stroke="#fff" stroke-opacity="0.9" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M34 9.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8.8-2.2Z" fill="#9be7c4"/>
|
||||||
|
<circle cx="38.5" cy="17.5" r="1.4" fill="#cdebff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 893 B |
@@ -7,13 +7,21 @@ api.interceptors.response.use(
|
|||||||
(r) => r,
|
(r) => r,
|
||||||
(err) => {
|
(err) => {
|
||||||
if (err.response?.status === 401) {
|
if (err.response?.status === 401) {
|
||||||
// Not signed in — kick off the Google login flow.
|
// Not signed in. Send the user to the public landing page (unless already
|
||||||
window.location.href = '/api/v1/auth/login?returnUrl=/';
|
// there) so they can read the features and choose to log in.
|
||||||
|
if (window.location.pathname !== '/') window.location.href = '/';
|
||||||
}
|
}
|
||||||
return Promise.reject(err);
|
return Promise.reject(err);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Top-level navigation that starts the Google flow and returns to the app.
|
||||||
|
export const LOGIN_URL = '/api/v1/auth/login?returnUrl=/app';
|
||||||
|
|
||||||
|
export const AppApi = {
|
||||||
|
info: () => api.get('/app/info').then((r) => r.data)
|
||||||
|
};
|
||||||
|
|
||||||
export const AuthApi = {
|
export const AuthApi = {
|
||||||
me: () => api.get('/auth/me').then((r) => r.data),
|
me: () => api.get('/auth/me').then((r) => r.data),
|
||||||
logout: () => api.post('/auth/logout')
|
logout: () => api.post('/auth/logout')
|
||||||
@@ -31,7 +39,11 @@ export const AnalyticsApi = {
|
|||||||
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),
|
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 = {
|
export const CleanupApi = {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AppApi } from '../api/client.js';
|
||||||
|
|
||||||
|
// Thin warning bar shown across the app when the backend reports dev mode.
|
||||||
|
export default function DevBanner() {
|
||||||
|
const [info, setInfo] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
AppApi.info().then(setInfo).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!info?.devMode) return null;
|
||||||
|
|
||||||
|
const cap = info.maxMessages > 0 ? ` · sync capped at ${info.maxMessages.toLocaleString()} most recent emails` : '';
|
||||||
|
return (
|
||||||
|
<div className="dev-banner">
|
||||||
|
⚠️ Dev / test mode ({info.environment}){cap}. Data and actions here are for testing.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,39 +1,289 @@
|
|||||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { AuthApi, SyncApi } from '../api/client.js';
|
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() {
|
export default function Layout() {
|
||||||
const [user, setUser] = useState(null);
|
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 loc = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => { AuthApi.me().then(setUser).catch(() => {}); }, []);
|
||||||
|
|
||||||
useEffect(() => {
|
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 = [
|
const nav = [
|
||||||
{ to: '/', label: 'Dashboard' },
|
{ to: '/app', label: 'Dashboard', end: true },
|
||||||
{ to: '/cleanup', label: 'Cleanup' },
|
{ to: '/app/senders', label: 'Senders' },
|
||||||
{ to: '/unsubscribe', label: 'Unsubscribe' }
|
{ to: '/app/cleanup', label: 'Cleanup' },
|
||||||
|
{ to: '/app/unsubscribe', label: 'Unsubscribe' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
|
<DevBanner />
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="brand">📥 InboxIntel</div>
|
<Link to="/app" className="brand-link"><Logo size={28} withWordmark /></Link>
|
||||||
<nav>
|
<nav>
|
||||||
{nav.map((n) => (
|
{nav.map((n) => (
|
||||||
<Link key={n.to} to={n.to} className={loc.pathname === n.to ? 'active' : ''}>
|
<Link key={n.to} to={n.to} className={isNavActive(n) ? 'active' : ''}>{n.label}</Link>
|
||||||
{n.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
<button onClick={() => SyncApi.incremental()}>Sync now</button>
|
<button onClick={startSync}>Sync now</button>
|
||||||
<span className="user">{user?.email}</span>
|
<span className="user">{user?.email}</span>
|
||||||
|
<button className="ghost" onClick={logout}>Log out</button>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
|
||||||
<Outlet />
|
<div className="app-body">
|
||||||
</main>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// InboxIntel mark: an inbox tray being "cleaned", with sparkles.
|
||||||
|
export default function Logo({ size = 32, withWordmark = false }) {
|
||||||
|
const mark = (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="InboxIntel logo">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="ii-grad" x1="0" y1="0" x2="48" y2="48" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stopColor="#4f8cff" />
|
||||||
|
<stop offset="1" stopColor="#3b6fe0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
{/* rounded badge */}
|
||||||
|
<rect x="2" y="2" width="44" height="44" rx="12" fill="url(#ii-grad)" />
|
||||||
|
{/* inbox tray */}
|
||||||
|
<path d="M12 21v11a3 3 0 0 0 3 3h18a3 3 0 0 0 3-3V21l-4.2 0a2 2 0 0 0-1.9 1.4l-.5 1.5a2 2 0 0 1-1.9 1.4h-6.2a2 2 0 0 1-1.9-1.4l-.5-1.5A2 2 0 0 0 16.2 21H12Z"
|
||||||
|
fill="#fff" fillOpacity="0.96" />
|
||||||
|
{/* tray opening line */}
|
||||||
|
<path d="M16 21l1.6-7.2A2 2 0 0 1 19.6 12h8.8a2 2 0 0 1 1.95 1.55L32 21"
|
||||||
|
stroke="#fff" strokeOpacity="0.9" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
{/* cleaning sparkles */}
|
||||||
|
<path d="M34 9.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8.8-2.2Z" fill="#9be7c4" />
|
||||||
|
<circle cx="38.5" cy="17.5" r="1.4" fill="#cdebff" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!withWordmark) return mark;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
{mark}
|
||||||
|
<span style={{ fontWeight: 800, fontSize: size * 0.62, letterSpacing: '-0.02em' }}>
|
||||||
|
Inbox<span style={{ color: '#4f8cff' }}>Intel</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import Logo from './Logo.jsx';
|
||||||
|
import { SyncApi } from '../api/client.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen overlay shown while the initial Gmail load runs. Polls
|
||||||
|
* /sync/status every 1.5s; calls onDone() when the sync stops running.
|
||||||
|
* Renders nothing once sync is idle/complete.
|
||||||
|
*/
|
||||||
|
export default function SyncSplash({ onDone }) {
|
||||||
|
const [progress, setProgress] = useState(null);
|
||||||
|
const timer = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const p = await SyncApi.status();
|
||||||
|
if (cancelled) return;
|
||||||
|
setProgress(p);
|
||||||
|
if (!p.isRunning) {
|
||||||
|
clearInterval(timer.current);
|
||||||
|
onDone?.(p);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore transient errors while polling */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
poll();
|
||||||
|
timer.current = setInterval(poll, 1500);
|
||||||
|
return () => { cancelled = true; clearInterval(timer.current); };
|
||||||
|
}, [onDone]);
|
||||||
|
|
||||||
|
if (!progress?.isRunning) return null;
|
||||||
|
|
||||||
|
const { processed = 0, total = 0 } = progress;
|
||||||
|
const pct = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="splash">
|
||||||
|
<div className="splash-card">
|
||||||
|
<Logo size={64} />
|
||||||
|
<h2>Loading your inbox…</h2>
|
||||||
|
<p className="muted">Syncing emails from Gmail. This can take a little while on first run.</p>
|
||||||
|
|
||||||
|
<div className="progress-track">
|
||||||
|
<div className="progress-fill" style={{ width: pct != null ? `${pct}%` : '40%' }} data-indeterminate={pct == null} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="progress-label">
|
||||||
|
{total > 0
|
||||||
|
? `${processed.toLocaleString()} / ${total.toLocaleString()} emails${pct != null ? ` (${pct}%)` : ''}`
|
||||||
|
: `${processed.toLocaleString()} emails synced…`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Bar, Line, Doughnut } from 'react-chartjs-2';
|
import { Bar, Line, Doughnut } from 'react-chartjs-2';
|
||||||
|
import { AnalyticsApi } from '../api/client.js';
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS, CategoryScale, LinearScale, BarElement, PointElement,
|
Chart as ChartJS, CategoryScale, LinearScale, BarElement, PointElement,
|
||||||
LineElement, ArcElement, Tooltip, Legend
|
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 }) {
|
export function AttachmentsWidget({ attachments }) {
|
||||||
if (!attachments) return <Empty />;
|
if (!attachments) return <Empty />;
|
||||||
const data = {
|
const data = {
|
||||||
|
|||||||
+14
-5
@@ -1,7 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import Landing from './pages/Landing.jsx';
|
||||||
import Dashboard from './pages/Dashboard.jsx';
|
import Dashboard from './pages/Dashboard.jsx';
|
||||||
|
import Senders from './pages/Senders.jsx';
|
||||||
import Cleanup from './pages/Cleanup.jsx';
|
import Cleanup from './pages/Cleanup.jsx';
|
||||||
import Unsubscribe from './pages/Unsubscribe.jsx';
|
import Unsubscribe from './pages/Unsubscribe.jsx';
|
||||||
import Layout from './components/Layout.jsx';
|
import Layout from './components/Layout.jsx';
|
||||||
@@ -11,11 +13,18 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
|||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
{/* Public landing page */}
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Landing />} />
|
||||||
<Route path="/cleanup" element={<Cleanup />} />
|
|
||||||
<Route path="/unsubscribe" element={<Unsubscribe />} />
|
{/* 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>
|
</Route>
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import GridLayout from 'react-grid-layout';
|
import GridLayout from 'react-grid-layout';
|
||||||
import 'react-grid-layout/css/styles.css';
|
import 'react-grid-layout/css/styles.css';
|
||||||
import 'react-resizable/css/styles.css';
|
import 'react-resizable/css/styles.css';
|
||||||
import { AnalyticsApi, LayoutApi, ExportApi } from '../api/client.js';
|
import { AnalyticsApi, LayoutApi, ExportApi, SyncApi } from '../api/client.js';
|
||||||
|
import SyncSplash from '../components/SyncSplash.jsx';
|
||||||
import {
|
import {
|
||||||
HealthWidget, StatCard, TopSendersWidget, VolumeWidget,
|
HealthWidget, StatCard, TopSendersWidget, VolumeWidget,
|
||||||
HeatmapWidget, AttachmentsWidget, StorageWidget
|
CategoryHeatmapWidget, AttachmentsWidget, StorageWidget
|
||||||
} from '../components/widgets.jsx';
|
} from '../components/widgets.jsx';
|
||||||
|
|
||||||
// Default grid geometry; overridden by the user's saved layout.
|
// Default grid geometry; overridden by the user's saved layout.
|
||||||
@@ -16,8 +17,8 @@ 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: 'heatmap', x: 0, y: 5, w: 6, h: 4 },
|
{ 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);
|
||||||
@@ -26,17 +27,49 @@ 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);
|
||||||
const [hidden, setHidden] = useState([]);
|
const [hidden, setHidden] = useState([]);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback(() => {
|
||||||
|
AnalyticsApi.dashboard().then(setData).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
AnalyticsApi.dashboard().then(setData).catch(() => {});
|
|
||||||
LayoutApi.get().then((saved) => {
|
LayoutApi.get().then((saved) => {
|
||||||
if (saved?.length) {
|
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));
|
setHidden(saved.filter((w) => !w.visible).map((w) => w.widgetKey));
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Decide whether to show the loading splash: if a sync is running, watch it;
|
||||||
|
// if the inbox has never been synced, kick one off automatically.
|
||||||
|
SyncApi.status().then((s) => {
|
||||||
|
if (s.isRunning) {
|
||||||
|
setSyncing(true);
|
||||||
|
} else if (!s.lastSuccessfulSyncUtc) {
|
||||||
|
SyncApi.incremental().then(() => setSyncing(true)).catch(loadData);
|
||||||
|
} else {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}).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();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
const persist = (nextLayout, nextHidden) => {
|
const persist = (nextLayout, nextHidden) => {
|
||||||
const dto = nextLayout.map((l, idx) => ({
|
const dto = nextLayout.map((l, idx) => ({
|
||||||
widgetKey: l.i, x: l.x, y: l.y, w: l.w, h: l.h,
|
widgetKey: l.i, x: l.x, y: l.y, w: l.w, h: l.h,
|
||||||
@@ -61,7 +94,7 @@ export default function Dashboard() {
|
|||||||
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 'heatmap': return <HeatmapWidget heatmap={data?.heatmap} />;
|
case 'category-heatmap': return <CategoryHeatmapWidget />;
|
||||||
case 'attachments': return <AttachmentsWidget attachments={data?.attachmentBreakdown} />;
|
case 'attachments': return <AttachmentsWidget attachments={data?.attachmentBreakdown} />;
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
@@ -69,6 +102,7 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
|
{syncing && <SyncSplash onDone={onSyncDone} />}
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<div className="widget-toggles">
|
<div className="widget-toggles">
|
||||||
{ALL_WIDGETS.map((k) => (
|
{ALL_WIDGETS.map((k) => (
|
||||||
@@ -89,6 +123,11 @@ export default function Dashboard() {
|
|||||||
cols={12}
|
cols={12}
|
||||||
rowHeight={60}
|
rowHeight={60}
|
||||||
width={1200}
|
width={1200}
|
||||||
|
isResizable
|
||||||
|
isDraggable
|
||||||
|
resizeHandles={['se']}
|
||||||
|
compactType="vertical"
|
||||||
|
margin={[12, 12]}
|
||||||
onLayoutChange={onLayoutChange}
|
onLayoutChange={onLayoutChange}
|
||||||
draggableHandle=".widget h3, .widget .stat-label"
|
draggableHandle=".widget h3, .widget .stat-label"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Logo from '../components/Logo.jsx';
|
||||||
|
import { AuthApi, LOGIN_URL } from '../api/client.js';
|
||||||
|
|
||||||
|
const FEATURES = [
|
||||||
|
{ icon: '🔌', title: 'Gmail, synced locally', text: 'Secure Google sign-in, then a full + incremental sync of your inbox into your own PostgreSQL — with retry, backoff, and resume.' },
|
||||||
|
{ icon: '📊', title: 'Analytics dashboard', text: 'Inbox health score, top senders, volume over time, an activity heatmap, and attachment breakdowns — on draggable, resizable widgets.' },
|
||||||
|
{ icon: '🧹', title: 'Safe bulk cleanup', text: 'Archive, label, or delete in bulk — always with a preview and explicit confirmation before anything destructive happens.' },
|
||||||
|
{ icon: '✉️', title: 'Unsubscribe manager', text: 'Detects List-Unsubscribe headers, groups by sender, ranks what is safe to drop, and runs a confirmed unsubscribe queue.' },
|
||||||
|
{ icon: '🔎', title: 'Advanced search', text: 'PostgreSQL full-text search with Gmail-like syntax: from:, domain:, after:, is:unread, has:attachment.' },
|
||||||
|
{ icon: '🤖', title: 'Optional AI', text: 'Toggle a local (Ollama) or cloud (OpenAI) model to classify mail, summarise your inbox, and suggest cleanups. Never destructive.' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Landing() {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Quietly check if already signed in to swap the CTA.
|
||||||
|
AuthApi.me().then(setUser).catch(() => setUser(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const primaryCta = user
|
||||||
|
? <button className="cta" onClick={() => navigate('/app')}>Open your dashboard →</button>
|
||||||
|
: <a className="cta" href={LOGIN_URL}>Sign in with Google</a>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="landing">
|
||||||
|
<header className="landing-bar">
|
||||||
|
<Logo size={34} withWordmark />
|
||||||
|
<div className="spacer" />
|
||||||
|
{user
|
||||||
|
? <button className="ghost" onClick={() => navigate('/app')}>Dashboard</button>
|
||||||
|
: <a className="ghost" href={LOGIN_URL}>Log in</a>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="hero">
|
||||||
|
<Logo size={72} />
|
||||||
|
<h1>Take back control of your inbox.</h1>
|
||||||
|
<p className="sub">
|
||||||
|
InboxIntel syncs your Gmail into your own database, shows you what is really going on,
|
||||||
|
and helps you clean it up safely — analytics, bulk actions, and one-click unsubscribe.
|
||||||
|
</p>
|
||||||
|
<div className="hero-cta">{primaryCta}</div>
|
||||||
|
<p className="fineprint">Google sign-in only. Your tokens are encrypted at rest and never logged.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="features">
|
||||||
|
{FEATURES.map((f) => (
|
||||||
|
<div className="feature" key={f.title}>
|
||||||
|
<div className="feature-icon">{f.icon}</div>
|
||||||
|
<h3>{f.title}</h3>
|
||||||
|
<p>{f.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="closing">
|
||||||
|
<h2>Ready to dig in?</h2>
|
||||||
|
{primaryCta}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="landing-footer">
|
||||||
|
<Logo size={20} withWordmark />
|
||||||
|
<span className="muted">Gmail analytics, cleanup & automation.</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+153
-1
@@ -19,7 +19,73 @@ body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-
|
|||||||
.spacer { flex: 1; }
|
.spacer { flex: 1; }
|
||||||
.user { color: var(--muted); font-size: 13px; }
|
.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, .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); }
|
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-day { width: 30px; font-size: 10px; color: var(--muted); }
|
||||||
.hm-cell { width: 10px; height: 10px; background: var(--accent); border-radius: 2px; }
|
.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; }
|
.page { max-width: 900px; }
|
||||||
.form-row { display: flex; gap: 10px; margin: 14px 0; }
|
.form-row { display: flex; gap: 10px; margin: 14px 0; }
|
||||||
.form-row input { flex: 1; }
|
.form-row input { flex: 1; }
|
||||||
@@ -61,3 +164,52 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
|
|||||||
.card { background: var(--panel); border: 1px solid #2c3550; border-radius: 10px; padding: 14px; margin-top: 12px; }
|
.card { background: var(--panel); border: 1px solid #2c3550; border-radius: 10px; padding: 14px; margin-top: 12px; }
|
||||||
.card.success { border-color: var(--ok); }
|
.card.success { border-color: var(--ok); }
|
||||||
.card ul { font-size: 13px; color: var(--muted); }
|
.card ul { font-size: 13px; color: var(--muted); }
|
||||||
|
|
||||||
|
/* ── Dev banner ── */
|
||||||
|
.dev-banner {
|
||||||
|
background: repeating-linear-gradient(45deg, #3a2e12, #3a2e12 12px, #332810 12px, #332810 24px);
|
||||||
|
color: #ffce6b; font-size: 13px; font-weight: 600; text-align: center;
|
||||||
|
padding: 6px 12px; border-bottom: 1px solid #5a4718;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shared buttons ── */
|
||||||
|
.brand-link { text-decoration: none; color: var(--text); display: inline-flex; }
|
||||||
|
.ghost { background: transparent; border: 1px solid #36405c; color: var(--text); }
|
||||||
|
.ghost:hover { border-color: var(--accent); }
|
||||||
|
.cta {
|
||||||
|
background: var(--accent); color: #fff; border: none; border-radius: 8px;
|
||||||
|
padding: 12px 22px; font-size: 16px; font-weight: 700; cursor: pointer;
|
||||||
|
text-decoration: none; display: inline-block;
|
||||||
|
}
|
||||||
|
.cta:hover { background: #5d97ff; }
|
||||||
|
|
||||||
|
/* ── Landing page ── */
|
||||||
|
.landing { max-width: 1080px; margin: 0 auto; padding: 0 20px 60px; }
|
||||||
|
.landing-bar { display: flex; align-items: center; padding: 18px 0; }
|
||||||
|
.hero { text-align: center; padding: 48px 0 36px; }
|
||||||
|
.hero h1 { font-size: 42px; margin: 18px 0 10px; letter-spacing: -0.02em; }
|
||||||
|
.hero .sub { color: var(--muted); font-size: 18px; max-width: 640px; margin: 0 auto 24px; line-height: 1.5; }
|
||||||
|
.hero-cta { margin: 8px 0; }
|
||||||
|
.fineprint { color: var(--muted); font-size: 12px; margin-top: 14px; }
|
||||||
|
.features { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-top: 24px; }
|
||||||
|
.feature { background: var(--panel); border: 1px solid #2c3550; border-radius: 12px; padding: 20px; }
|
||||||
|
.feature-icon { font-size: 26px; }
|
||||||
|
.feature h3 { margin: 10px 0 6px; font-size: 17px; }
|
||||||
|
.feature p { color: var(--muted); font-size: 14px; line-height: 1.5; margin: 0; }
|
||||||
|
.closing { text-align: center; margin: 56px 0 20px; }
|
||||||
|
.closing h2 { font-size: 28px; margin-bottom: 18px; }
|
||||||
|
.landing-footer { display: flex; align-items: center; gap: 12px; justify-content: center; margin-top: 40px; padding-top: 20px; border-top: 1px solid #2c3550; }
|
||||||
|
|
||||||
|
/* ── Sync splash ── */
|
||||||
|
.splash {
|
||||||
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(10, 14, 22, 0.92); backdrop-filter: blur(4px);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.splash-card { text-align: center; max-width: 440px; padding: 32px; }
|
||||||
|
.splash-card h2 { margin: 16px 0 6px; }
|
||||||
|
.progress-track { height: 10px; background: var(--panel-2); border-radius: 6px; overflow: hidden; margin: 20px 0 10px; }
|
||||||
|
.progress-fill { height: 100%; background: linear-gradient(90deg, #4f8cff, #6fcf97); border-radius: 6px; transition: width 0.4s ease; }
|
||||||
|
.progress-fill[data-indeterminate="true"] { animation: indet 1.2s ease-in-out infinite; }
|
||||||
|
@keyframes indet { 0% { margin-left: -40%; } 100% { margin-left: 100%; } }
|
||||||
|
.progress-label { color: var(--muted); font-size: 13px; }
|
||||||
|
|||||||
+5
-5
@@ -8,25 +8,25 @@ server {
|
|||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api_upstream;
|
proxy_pass http://api_upstream;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
proxy_set_header Cookie $http_cookie;
|
proxy_set_header Cookie $http_cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Google OAuth2 callback + sign-out -> backend (same-origin session).
|
# Google OAuth2 callback + sign-out -> backend (same-origin session).
|
||||||
location ~ ^/(signin-google|signout-google) {
|
location ~ ^/(signin-google|signout-google) {
|
||||||
proxy_pass http://api_upstream;
|
proxy_pass http://api_upstream;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
proxy_set_header Cookie $http_cookie;
|
proxy_set_header Cookie $http_cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend_upstream;
|
proxy_pass http://frontend_upstream;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $http_host;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ public class AnalyticsController : ApiControllerBase
|
|||||||
[HttpGet("heatmap")]
|
[HttpGet("heatmap")]
|
||||||
public async Task<IActionResult> Heatmap(CancellationToken ct) => Ok(await _analytics.GetHeatmapAsync(UserId, ct));
|
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")]
|
[HttpGet("attachments")]
|
||||||
public async Task<IActionResult> Attachments(CancellationToken ct) => Ok(await _analytics.GetAttachmentBreakdownAsync(UserId, ct));
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Asp.Versioning;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace InboxIntel.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Public metadata the SPA reads on load to decide whether to show the dev
|
||||||
|
/// banner. devMode falls back to the hosting environment when App:DevMode is
|
||||||
|
/// unset, and can be forced on/off via the App:DevMode config / App__DevMode env.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[ApiVersion("1.0")]
|
||||||
|
[Route("api/v{version:apiVersion}/app")]
|
||||||
|
public class AppInfoController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
private readonly IWebHostEnvironment _env;
|
||||||
|
|
||||||
|
public AppInfoController(IConfiguration config, IWebHostEnvironment env)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_env = env;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("info")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public IActionResult Info()
|
||||||
|
{
|
||||||
|
var devMode = _config.GetValue<bool?>("App:DevMode") ?? _env.IsDevelopment();
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
environment = _env.EnvironmentName,
|
||||||
|
devMode,
|
||||||
|
maxMessages = _config.GetValue<int>("GmailSync:MaxMessages")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,20 +10,21 @@ public class SyncController : ApiControllerBase
|
|||||||
|
|
||||||
[HttpGet("status")]
|
[HttpGet("status")]
|
||||||
public async Task<IActionResult> Status(CancellationToken ct)
|
public async Task<IActionResult> Status(CancellationToken ct)
|
||||||
=> Ok(new { status = (await _sync.GetStatusAsync(UserId, ct)).ToString() });
|
=> Ok(await _sync.GetProgressAsync(UserId, ct));
|
||||||
|
|
||||||
/// <summary>Triggers a full inbox sync (runs in the background task queue in production).</summary>
|
/// <summary>Queues a full inbox sync. Returns immediately; poll /sync/status for progress.</summary>
|
||||||
[HttpPost("full")]
|
[HttpPost("full")]
|
||||||
public async Task<IActionResult> Full(CancellationToken ct)
|
public async Task<IActionResult> Full(CancellationToken ct)
|
||||||
{
|
{
|
||||||
await _sync.RunFullSyncAsync(UserId, ct);
|
await _sync.QueueSyncAsync(UserId, fullSync: true, ct);
|
||||||
return Accepted();
|
return Accepted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Queues an incremental sync (becomes a full sync on first run).</summary>
|
||||||
[HttpPost("incremental")]
|
[HttpPost("incremental")]
|
||||||
public async Task<IActionResult> Incremental(CancellationToken ct)
|
public async Task<IActionResult> Incremental(CancellationToken ct)
|
||||||
{
|
{
|
||||||
await _sync.RunIncrementalSyncAsync(UserId, ct);
|
await _sync.QueueSyncAsync(UserId, fullSync: false, ct);
|
||||||
return Accepted();
|
return Accepted();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<RootNamespace>InboxIntel.Api</RootNamespace>
|
<RootNamespace>InboxIntel.Api</RootNamespace>
|
||||||
<AssemblyName>InboxIntel.Api</AssemblyName>
|
<AssemblyName>InboxIntel.Api</AssemblyName>
|
||||||
|
<UserSecretsId>210c6d96-c7e4-4ee9-8982-8b91424979b8</UserSecretsId>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.7" />
|
||||||
|
|||||||
@@ -38,13 +38,30 @@ var google = builder.Configuration.GetSection(GoogleOAuthOptions.SectionName).Ge
|
|||||||
builder.Services.AddAuthentication(options =>
|
builder.Services.AddAuthentication(options =>
|
||||||
{
|
{
|
||||||
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||||
options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;
|
// Challenge via the cookie scheme so unauthenticated API (XHR) calls get a
|
||||||
|
// 401 instead of a redirect to Google. The SPA's axios interceptor turns
|
||||||
|
// that 401 into a top-level navigation to /auth/login, which then starts
|
||||||
|
// the Google flow explicitly. (A 302 to Google on an XHR is CORS-blocked.)
|
||||||
|
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||||
})
|
})
|
||||||
.AddCookie(options =>
|
.AddCookie(options =>
|
||||||
{
|
{
|
||||||
options.Cookie.HttpOnly = true;
|
options.Cookie.HttpOnly = true;
|
||||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
|
options.Cookie.Name = "inboxintel.session";
|
||||||
options.ExpireTimeSpan = TimeSpan.FromDays(7);
|
options.ExpireTimeSpan = TimeSpan.FromDays(7);
|
||||||
|
options.SlidingExpiration = true;
|
||||||
|
// API-style behaviour: return status codes rather than redirecting to a login page.
|
||||||
|
options.Events.OnRedirectToLogin = ctx =>
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
options.Events.OnRedirectToAccessDenied = ctx =>
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.AddGoogle(options =>
|
.AddGoogle(options =>
|
||||||
{
|
{
|
||||||
@@ -54,6 +71,14 @@ builder.Services.AddAuthentication(options =>
|
|||||||
options.SaveTokens = true;
|
options.SaveTokens = true;
|
||||||
foreach (var scope in google.Scopes) options.Scope.Add(scope);
|
foreach (var scope in google.Scopes) options.Scope.Add(scope);
|
||||||
options.Events.OnCreatingTicket = GoogleAuthEvents.OnCreatingTicketAsync;
|
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();
|
builder.Services.AddAuthorization();
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
"DataProtection": {
|
"DataProtection": {
|
||||||
"KeyPath": "./keys"
|
"KeyPath": "./keys"
|
||||||
},
|
},
|
||||||
|
"App": {
|
||||||
|
"DevMode": true
|
||||||
|
},
|
||||||
|
"GmailSync": {
|
||||||
|
"MaxMessages": 1000
|
||||||
|
},
|
||||||
"Serilog": {
|
"Serilog": {
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Debug"
|
"Default": "Debug"
|
||||||
|
|||||||
@@ -19,12 +19,16 @@
|
|||||||
"https://www.googleapis.com/auth/gmail.modify"
|
"https://www.googleapis.com/auth/gmail.modify"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"App": {
|
||||||
|
"DevMode": false
|
||||||
|
},
|
||||||
"GmailSync": {
|
"GmailSync": {
|
||||||
"PageSize": 100,
|
"PageSize": 500,
|
||||||
"MaxParallelism": 4,
|
"MaxParallelism": 8,
|
||||||
"MaxRetries": 5,
|
"MaxRetries": 5,
|
||||||
"BackoffBaseMs": 500,
|
"BackoffBaseMs": 500,
|
||||||
"DailySyncHourUtc": 3
|
"DailySyncHourUtc": 3,
|
||||||
|
"MaxMessages": 0
|
||||||
},
|
},
|
||||||
"Ai": {
|
"Ai": {
|
||||||
"Mode": "Disabled",
|
"Mode": "Disabled",
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ public interface ISyncService
|
|||||||
{
|
{
|
||||||
Task RunFullSyncAsync(Guid userId, CancellationToken ct = default);
|
Task RunFullSyncAsync(Guid userId, CancellationToken ct = default);
|
||||||
Task RunIncrementalSyncAsync(Guid userId, CancellationToken ct = default);
|
Task RunIncrementalSyncAsync(Guid userId, CancellationToken ct = default);
|
||||||
|
/// <summary>Marks the sync as starting and queues it for the background worker.</summary>
|
||||||
|
Task QueueSyncAsync(Guid userId, bool fullSync, CancellationToken ct = default);
|
||||||
Task<SyncStatus> GetStatusAsync(Guid userId, CancellationToken ct = default);
|
Task<SyncStatus> GetStatusAsync(Guid userId, CancellationToken ct = default);
|
||||||
|
/// <summary>Progress snapshot for the sync splash screen.</summary>
|
||||||
|
Task<DTOs.SyncProgressDto> GetProgressAsync(Guid userId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IAnalyticsService
|
public interface IAnalyticsService
|
||||||
@@ -19,7 +23,9 @@ public interface IAnalyticsService
|
|||||||
Task<IReadOnlyList<SenderStatDto>> GetTopSendersAsync(Guid userId, int take = 20, CancellationToken ct = default);
|
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<TimeSeriesPointDto>> GetVolumeOverTimeAsync(Guid userId, int days = 90, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<HeatmapCellDto>> GetHeatmapAsync(Guid userId, 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<IReadOnlyList<AttachmentBreakdownDto>> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default);
|
||||||
|
Task<SidebarCountsDto> GetSidebarCountsAsync(Guid userId, CancellationToken ct = default);
|
||||||
Task RefreshAggregatesAsync(Guid userId, CancellationToken ct = default);
|
Task RefreshAggregatesAsync(Guid userId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace InboxIntel.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hands sync work to a background worker so HTTP triggers return immediately
|
||||||
|
/// and the UI can poll progress (non-blocking architecture).
|
||||||
|
/// </summary>
|
||||||
|
public interface ISyncQueue
|
||||||
|
{
|
||||||
|
void Enqueue(Guid userId, bool fullSync);
|
||||||
|
}
|
||||||
@@ -14,6 +14,23 @@ public record TimeSeriesPointDto(DateOnly Day, int Count);
|
|||||||
|
|
||||||
public record HeatmapCellDto(int DayOfWeek, int Hour, 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 AttachmentBreakdownDto(string MimeBucket, long TotalBytes, int Count);
|
||||||
|
|
||||||
public record DashboardSummaryDto(
|
public record DashboardSummaryDto(
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace InboxIntel.Application.DTOs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live sync progress for the splash screen. <see cref="Total"/> reflects the
|
||||||
|
/// dev cap when one is configured, so the progress bar fills correctly.
|
||||||
|
/// </summary>
|
||||||
|
public record SyncProgressDto(
|
||||||
|
string Status,
|
||||||
|
int Processed,
|
||||||
|
int Total,
|
||||||
|
bool IsRunning,
|
||||||
|
DateTimeOffset? LastSuccessfulSyncUtc,
|
||||||
|
string? LastError);
|
||||||
@@ -88,6 +88,19 @@ public class AnalyticsService : IAnalyticsService
|
|||||||
.ToList();
|
.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)
|
public async Task<IReadOnlyList<AttachmentBreakdownDto>> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var raw = await _db.Attachments
|
var raw = await _db.Attachments
|
||||||
@@ -115,6 +128,60 @@ public class AnalyticsService : IAnalyticsService
|
|||||||
_ => "other"
|
_ => "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)
|
public async Task RefreshAggregatesAsync(Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var since = DateTimeOffset.UtcNow.AddDays(-365);
|
var since = DateTimeOffset.UtcNow.AddDays(-365);
|
||||||
|
|||||||
@@ -19,13 +19,20 @@ public class GoogleOAuthOptions
|
|||||||
public class GmailSyncOptions
|
public class GmailSyncOptions
|
||||||
{
|
{
|
||||||
public const string SectionName = "GmailSync";
|
public const string SectionName = "GmailSync";
|
||||||
public int PageSize { get; set; } = 100;
|
/// <summary>Message-id list page size (Gmail max is 500).</summary>
|
||||||
public int MaxParallelism { get; set; } = 4;
|
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;
|
public int MaxRetries { get; set; } = 5;
|
||||||
/// <summary>Base delay in ms for exponential backoff.</summary>
|
/// <summary>Base delay in ms for exponential backoff.</summary>
|
||||||
public int BackoffBaseMs { get; set; } = 500;
|
public int BackoffBaseMs { get; set; } = 500;
|
||||||
/// <summary>Cron-like daily sync hour (UTC) for the scheduled worker.</summary>
|
/// <summary>Cron-like daily sync hour (UTC) for the scheduled worker.</summary>
|
||||||
public int DailySyncHourUtc { get; set; } = 3;
|
public int DailySyncHourUtc { get; set; } = 3;
|
||||||
|
/// <summary>
|
||||||
|
/// Cap on messages stored during a full sync. 0 = unlimited. In dev this is
|
||||||
|
/// set to 1000 so the initial load pulls only the most recent emails.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxMessages { get; set; } = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AiOptions
|
public class AiOptions
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<GmailClientFactory>();
|
services.AddScoped<GmailClientFactory>();
|
||||||
services.AddScoped<IGmailService, GmailApiService>();
|
services.AddScoped<IGmailService, GmailApiService>();
|
||||||
|
|
||||||
|
// Background sync queue (singleton) + its worker.
|
||||||
|
services.AddSingleton<SyncQueue>();
|
||||||
|
services.AddSingleton<ISyncQueue>(sp => sp.GetRequiredService<SyncQueue>());
|
||||||
|
services.AddHostedService<SyncQueueWorker>();
|
||||||
|
|
||||||
// Core services
|
// Core services
|
||||||
services.AddScoped<ISyncService, SyncService>();
|
services.AddScoped<ISyncService, SyncService>();
|
||||||
services.AddScoped<IAnalyticsService, AnalyticsService>();
|
services.AddScoped<IAnalyticsService, AnalyticsService>();
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ public class GmailApiService : IGmailService
|
|||||||
private readonly ILogger<GmailApiService> _logger;
|
private readonly ILogger<GmailApiService> _logger;
|
||||||
private readonly ResiliencePipeline _pipeline;
|
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)
|
public GmailApiService(GmailClientFactory factory, IOptions<GmailSyncOptions> options, ILogger<GmailApiService> logger)
|
||||||
{
|
{
|
||||||
_factory = factory;
|
_factory = factory;
|
||||||
@@ -56,9 +62,26 @@ public class GmailApiService : IGmailService
|
|||||||
or HttpStatusCode.ServiceUnavailable
|
or HttpStatusCode.ServiceUnavailable
|
||||||
or HttpStatusCode.GatewayTimeout;
|
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)
|
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 =>
|
var profile = await _pipeline.ExecuteAsync(async token =>
|
||||||
await client.Users.GetProfile("me").ExecuteAsync(token), ct);
|
await client.Users.GetProfile("me").ExecuteAsync(token), ct);
|
||||||
return profile?.HistoryId?.ToString() ?? throw new InvalidOperationException("Gmail profile returned no historyId.");
|
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)
|
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 page = await _pipeline.ExecuteAsync(async token =>
|
||||||
{
|
{
|
||||||
var req = client.Users.Messages.List("me");
|
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)
|
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 msg = await _pipeline.ExecuteAsync(async token =>
|
||||||
{
|
{
|
||||||
var req = client.Users.Messages.Get("me", gmailMessageId);
|
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)
|
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 history = await _pipeline.ExecuteAsync(async token =>
|
||||||
{
|
{
|
||||||
var req = client.Users.History.List("me");
|
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)
|
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 =>
|
var resp = await _pipeline.ExecuteAsync(async token =>
|
||||||
await client.Users.Labels.List("me").ExecuteAsync(token), ct);
|
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)
|
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
|
var body = new BatchModifyMessagesRequest
|
||||||
{
|
{
|
||||||
Ids = messageIds.ToList(),
|
Ids = messageIds.ToList(),
|
||||||
@@ -148,7 +171,7 @@ public class GmailApiService : IGmailService
|
|||||||
|
|
||||||
public async Task BatchTrashAsync(Guid userId, IEnumerable<string> messageIds, CancellationToken ct = default)
|
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)
|
foreach (var id in messageIds)
|
||||||
{
|
{
|
||||||
await _pipeline.ExecuteAsync(async token =>
|
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)
|
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() };
|
var body = new BatchDeleteMessagesRequest { Ids = messageIds.ToList() };
|
||||||
await _pipeline.ExecuteAsync(async token =>
|
await _pipeline.ExecuteAsync(async token =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.Threading.Channels;
|
||||||
|
using InboxIntel.Application.Abstractions;
|
||||||
|
|
||||||
|
namespace InboxIntel.Infrastructure.Sync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-process unbounded queue of sync jobs, backed by a Channel. Registered as
|
||||||
|
/// a singleton; the controller writes, <see cref="SyncQueueWorker"/> reads.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SyncQueue : ISyncQueue
|
||||||
|
{
|
||||||
|
private readonly Channel<(Guid UserId, bool Full)> _channel =
|
||||||
|
Channel.CreateUnbounded<(Guid, bool)>(new UnboundedChannelOptions { SingleReader = true });
|
||||||
|
|
||||||
|
public void Enqueue(Guid userId, bool fullSync) => _channel.Writer.TryWrite((userId, fullSync));
|
||||||
|
|
||||||
|
public ChannelReader<(Guid UserId, bool Full)> Reader => _channel.Reader;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using InboxIntel.Application.Abstractions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace InboxIntel.Infrastructure.Sync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drains the <see cref="SyncQueue"/> and runs each sync in its own DI scope,
|
||||||
|
/// then refreshes analytics aggregates. Keeps sync work off the request thread.
|
||||||
|
/// </summary>
|
||||||
|
public class SyncQueueWorker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly SyncQueue _queue;
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<SyncQueueWorker> _logger;
|
||||||
|
|
||||||
|
public SyncQueueWorker(SyncQueue queue, IServiceScopeFactory scopeFactory, ILogger<SyncQueueWorker> logger)
|
||||||
|
{
|
||||||
|
_queue = queue;
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await foreach (var (userId, full) in _queue.Reader.ReadAllAsync(stoppingToken))
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var sync = scope.ServiceProvider.GetRequiredService<ISyncService>();
|
||||||
|
var analytics = scope.ServiceProvider.GetRequiredService<IAnalyticsService>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (full) await sync.RunFullSyncAsync(userId, stoppingToken);
|
||||||
|
else await sync.RunIncrementalSyncAsync(userId, stoppingToken);
|
||||||
|
await analytics.RefreshAggregatesAsync(userId, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Queued sync failed for user {UserId}", userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
using InboxIntel.Application.Abstractions;
|
using InboxIntel.Application.Abstractions;
|
||||||
|
using InboxIntel.Application.DTOs;
|
||||||
using InboxIntel.Domain.Entities;
|
using InboxIntel.Domain.Entities;
|
||||||
using InboxIntel.Domain.Enums;
|
using InboxIntel.Domain.Enums;
|
||||||
|
using InboxIntel.Infrastructure.Configuration;
|
||||||
using InboxIntel.Infrastructure.Gmail;
|
using InboxIntel.Infrastructure.Gmail;
|
||||||
using InboxIntel.Infrastructure.Persistence;
|
using InboxIntel.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace InboxIntel.Infrastructure.Sync;
|
namespace InboxIntel.Infrastructure.Sync;
|
||||||
|
|
||||||
@@ -19,17 +22,48 @@ public class SyncService : ISyncService
|
|||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IGmailService _gmail;
|
private readonly IGmailService _gmail;
|
||||||
private readonly ILogger<SyncService> _logger;
|
private readonly ILogger<SyncService> _logger;
|
||||||
|
private readonly GmailSyncOptions _options;
|
||||||
|
private readonly ISyncQueue _queue;
|
||||||
|
|
||||||
public SyncService(AppDbContext db, IGmailService gmail, ILogger<SyncService> logger)
|
public SyncService(AppDbContext db, IGmailService gmail, ILogger<SyncService> logger, IOptions<GmailSyncOptions> options, ISyncQueue queue)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_gmail = gmail;
|
_gmail = gmail;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_options = options.Value;
|
||||||
|
_queue = queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task QueueSyncAsync(Guid userId, bool fullSync, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Flip to Running synchronously so the UI splash shows immediately,
|
||||||
|
// then hand the actual work to the background worker.
|
||||||
|
var state = await GetOrCreateStateAsync(userId, ct);
|
||||||
|
state.Status = SyncStatus.Running;
|
||||||
|
state.LastSyncType = fullSync ? SyncType.Full : SyncType.Incremental;
|
||||||
|
state.StartedUtc = DateTimeOffset.UtcNow;
|
||||||
|
state.LastError = null;
|
||||||
|
if (fullSync) state.MessagesProcessed = 0;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
_queue.Enqueue(userId, fullSync);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SyncStatus> GetStatusAsync(Guid userId, CancellationToken ct = default)
|
public async Task<SyncStatus> GetStatusAsync(Guid userId, CancellationToken ct = default)
|
||||||
=> (await GetOrCreateStateAsync(userId, ct)).Status;
|
=> (await GetOrCreateStateAsync(userId, ct)).Status;
|
||||||
|
|
||||||
|
public async Task<SyncProgressDto> GetProgressAsync(Guid userId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var s = await GetOrCreateStateAsync(userId, ct);
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task RunFullSyncAsync(Guid userId, CancellationToken ct = default)
|
public async Task RunFullSyncAsync(Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var state = await GetOrCreateStateAsync(userId, ct);
|
var state = await GetOrCreateStateAsync(userId, ct);
|
||||||
@@ -43,25 +77,50 @@ public class SyncService : ISyncService
|
|||||||
{
|
{
|
||||||
await SyncLabelsAsync(userId, ct);
|
await SyncLabelsAsync(userId, ct);
|
||||||
|
|
||||||
string? pageToken = state.ResumePageToken; // resume support
|
// Dev cap: stop after MaxMessages (most recent first). 0 = unlimited.
|
||||||
|
var maxMessages = _options.MaxMessages;
|
||||||
|
|
||||||
|
// 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
|
do
|
||||||
{
|
{
|
||||||
var page = await _gmail.ListMessageIdsAsync(userId, pageToken, ct);
|
var page = await _gmail.ListMessageIdsAsync(userId, listToken, ct);
|
||||||
foreach (var messageId in page.MessageIds)
|
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 (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);
|
await UpsertMessageAsync(userId, detail, ct);
|
||||||
state.MessagesProcessed++;
|
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 = page.ResultSizeEstimate;
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
while (pageToken is not null && !ct.IsCancellationRequested);
|
|
||||||
|
|
||||||
state.LastHistoryId = await _gmail.GetProfileHistoryIdAsync(userId, ct);
|
state.LastHistoryId = await _gmail.GetProfileHistoryIdAsync(userId, ct);
|
||||||
state.ResumePageToken = null;
|
state.ResumePageToken = null;
|
||||||
@@ -132,9 +191,14 @@ public class SyncService : ISyncService
|
|||||||
private async Task MarkFailedAsync(SyncState state, Exception ex, CancellationToken ct)
|
private async Task MarkFailedAsync(SyncState state, Exception ex, CancellationToken ct)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Sync failed for user {UserId}", state.UserId);
|
_logger.LogError(ex, "Sync failed for user {UserId}", state.UserId);
|
||||||
state.Status = SyncStatus.Failed;
|
// Discard the failed batch's pending changes; otherwise saving the
|
||||||
state.ConsecutiveFailures++;
|
// failure status re-attempts the same bad inserts and throws again.
|
||||||
state.LastError = ex.Message;
|
_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);
|
await _db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,9 +238,9 @@ public class SyncService : ISyncService
|
|||||||
GmailMessageId = d.GmailMessageId,
|
GmailMessageId = d.GmailMessageId,
|
||||||
ThreadId = thread.Id,
|
ThreadId = thread.Id,
|
||||||
SenderId = sender.Id,
|
SenderId = sender.Id,
|
||||||
Subject = d.Subject,
|
Subject = Trunc(d.Subject, 1024),
|
||||||
Snippet = d.Snippet,
|
Snippet = Trunc(d.Snippet, 2048),
|
||||||
BodyText = d.BodyText,
|
BodyText = d.BodyText, // unlimited (text column)
|
||||||
SentAtUtc = d.SentAtUtc,
|
SentAtUtc = d.SentAtUtc,
|
||||||
ReceivedAtUtc = d.SentAtUtc,
|
ReceivedAtUtc = d.SentAtUtc,
|
||||||
SizeEstimateBytes = d.SizeEstimateBytes,
|
SizeEstimateBytes = d.SizeEstimateBytes,
|
||||||
@@ -186,7 +250,7 @@ public class SyncService : ISyncService
|
|||||||
IsImportant = d.LabelIds.Contains("IMPORTANT"),
|
IsImportant = d.LabelIds.Contains("IMPORTANT"),
|
||||||
HasAttachments = d.HasAttachments,
|
HasAttachments = d.HasAttachments,
|
||||||
HasListUnsubscribe = d.HasListUnsubscribe,
|
HasListUnsubscribe = d.HasListUnsubscribe,
|
||||||
ListUnsubscribeRaw = d.ListUnsubscribeRaw,
|
ListUnsubscribeRaw = Trunc(d.ListUnsubscribeRaw, 2048),
|
||||||
SupportsOneClickUnsubscribe = d.SupportsOneClickUnsubscribe,
|
SupportsOneClickUnsubscribe = d.SupportsOneClickUnsubscribe,
|
||||||
Category = HeuristicClassifier.Classify(d)
|
Category = HeuristicClassifier.Classify(d)
|
||||||
};
|
};
|
||||||
@@ -196,8 +260,8 @@ public class SyncService : ISyncService
|
|||||||
{
|
{
|
||||||
_db.Attachments.Add(new Attachment
|
_db.Attachments.Add(new Attachment
|
||||||
{
|
{
|
||||||
UserId = userId, EmailId = email.Id, FileName = fileName,
|
UserId = userId, EmailId = email.Id, FileName = Trunc(fileName, 512) ?? string.Empty,
|
||||||
MimeType = mime, SizeBytes = size, GmailAttachmentId = attId
|
MimeType = Trunc(mime, 255), SizeBytes = size, GmailAttachmentId = attId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,13 +276,37 @@ public class SyncService : ISyncService
|
|||||||
thread.LastMessageUtc = d.SentAtUtc;
|
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)
|
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;
|
if (sender is not null) return sender;
|
||||||
|
|
||||||
var domainName = address.Contains('@') ? address[(address.IndexOf('@') + 1)..] : "unknown";
|
address = Trunc(address, 320)!;
|
||||||
var domain = await _db.Domains.FirstOrDefaultAsync(x => x.UserId == userId && x.Name == domainName, ct);
|
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)
|
if (domain is null)
|
||||||
{
|
{
|
||||||
domain = new MailDomain { UserId = userId, Name = domainName };
|
domain = new MailDomain { UserId = userId, Name = domainName };
|
||||||
@@ -226,19 +314,24 @@ public class SyncService : ISyncService
|
|||||||
}
|
}
|
||||||
domain.EmailCount++;
|
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);
|
_db.Senders.Add(sender);
|
||||||
return 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)
|
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;
|
if (thread is not null) return thread;
|
||||||
thread = new MailThread
|
thread = new MailThread
|
||||||
{
|
{
|
||||||
UserId = userId, GmailThreadId = gmailThreadId, Subject = subject,
|
UserId = userId, GmailThreadId = gmailThreadId, Subject = Trunc(subject, 1024),
|
||||||
Snippet = snippet, FirstMessageUtc = sentAt
|
Snippet = Trunc(snippet, 2048), FirstMessageUtc = sentAt
|
||||||
};
|
};
|
||||||
_db.Threads.Add(thread);
|
_db.Threads.Add(thread);
|
||||||
return thread;
|
return thread;
|
||||||
|
|||||||
Reference in New Issue
Block a user