feat: landing page + logo, dev-mode banner + sync cap, non-blocking sync with progress splash

This commit is contained in:
cesnimda
2026-06-30 16:58:35 +02:00
parent dcb939e4f2
commit fe5920919f
29 changed files with 552 additions and 39 deletions
+5
View File
@@ -10,3 +10,8 @@ AI_MODE=Disabled
# Origin the API allows for CORS (the frontend container).
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
+4
View File
@@ -27,6 +27,10 @@ services:
GoogleOAuth__ClientId: ${GOOGLE_CLIENT_ID:-}
GoogleOAuth__ClientSecret: ${GOOGLE_CLIENT_SECRET:-}
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}
volumes:
- keys:/keys
+2 -1
View File
@@ -2,8 +2,9 @@
<html lang="en">
<head>
<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" />
<title>InboxIntel</title>
<title>InboxIntel — Gmail analytics & cleanup</title>
</head>
<body>
<div id="root"></div>
+4 -4
View File
@@ -12,10 +12,10 @@ server {
# Proxy API + auth calls to the backend container.
location /api/ {
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-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header Cookie $http_cookie;
}
@@ -23,10 +23,10 @@ server {
# reach the backend so the cookie session is established same-origin.
location ~ ^/(signin-google|signout-google) {
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-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header Cookie $http_cookie;
}
}
+12
View File
@@ -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

+10 -2
View File
@@ -7,13 +7,21 @@ api.interceptors.response.use(
(r) => r,
(err) => {
if (err.response?.status === 401) {
// Not signed in — kick off the Google login flow.
window.location.href = '/api/v1/auth/login?returnUrl=/';
// Not signed in. Send the user to the public landing page (unless already
// there) so they can read the features and choose to log in.
if (window.location.pathname !== '/') window.location.href = '/';
}
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 = {
me: () => api.get('/auth/me').then((r) => r.data),
logout: () => api.post('/auth/logout')
+20
View File
@@ -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>
);
}
+18 -8
View File
@@ -1,35 +1,45 @@
import { Link, Outlet, useLocation } from 'react-router-dom';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { AuthApi, SyncApi } from '../api/client.js';
import Logo from './Logo.jsx';
import DevBanner from './DevBanner.jsx';
export default function Layout() {
const [user, setUser] = useState(null);
const loc = useLocation();
const navigate = useNavigate();
useEffect(() => {
AuthApi.me().then(setUser).catch(() => {});
}, []);
const nav = [
{ to: '/', label: 'Dashboard' },
{ to: '/cleanup', label: 'Cleanup' },
{ to: '/unsubscribe', label: 'Unsubscribe' }
{ to: '/app', label: 'Dashboard', end: true },
{ to: '/app/cleanup', label: 'Cleanup' },
{ to: '/app/unsubscribe', label: 'Unsubscribe' }
];
const isActive = (n) => (n.end ? loc.pathname === n.to : loc.pathname.startsWith(n.to));
const logout = async () => {
try { await AuthApi.logout(); } catch { /* ignore */ }
navigate('/');
};
return (
<div className="app">
<DevBanner />
<header className="topbar">
<div className="brand">📥 InboxIntel</div>
<Link to="/app" className="brand-link"><Logo size={28} withWordmark /></Link>
<nav>
{nav.map((n) => (
<Link key={n.to} to={n.to} className={loc.pathname === n.to ? 'active' : ''}>
{n.label}
</Link>
<Link key={n.to} to={n.to} className={isActive(n) ? 'active' : ''}>{n.label}</Link>
))}
</nav>
<div className="spacer" />
<button onClick={() => SyncApi.incremental()}>Sync now</button>
<span className="user">{user?.email}</span>
<button className="ghost" onClick={logout}>Log out</button>
</header>
<main>
<Outlet />
+35
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+12 -5
View File
@@ -1,6 +1,7 @@
import React from 'react';
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 Cleanup from './pages/Cleanup.jsx';
import Unsubscribe from './pages/Unsubscribe.jsx';
@@ -11,11 +12,17 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/cleanup" element={<Cleanup />} />
<Route path="/unsubscribe" element={<Unsubscribe />} />
{/* Public landing page */}
<Route path="/" element={<Landing />} />
{/* Authenticated app */}
<Route path="/app" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="cleanup" element={<Cleanup />} />
<Route path="unsubscribe" element={<Unsubscribe />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</React.StrictMode>
+27 -4
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import GridLayout from 'react-grid-layout';
import 'react-grid-layout/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 {
HealthWidget, StatCard, TopSendersWidget, VolumeWidget,
HeatmapWidget, AttachmentsWidget, StorageWidget
@@ -26,16 +27,37 @@ export default function Dashboard() {
const [data, setData] = useState(null);
const [layout, setLayout] = useState(DEFAULT_LAYOUT);
const [hidden, setHidden] = useState([]);
const [syncing, setSyncing] = useState(false);
const loadData = useCallback(() => {
AnalyticsApi.dashboard().then(setData).catch(() => {});
}, []);
useEffect(() => {
AnalyticsApi.dashboard().then(setData).catch(() => {});
LayoutApi.get().then((saved) => {
if (saved?.length) {
setLayout(saved.map((w) => ({ i: w.widgetKey, x: w.x, y: w.y, w: w.w, h: w.h })));
setHidden(saved.filter((w) => !w.visible).map((w) => w.widgetKey));
}
}).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]);
const onSyncDone = useCallback(() => {
setSyncing(false);
loadData();
}, [loadData]);
const persist = (nextLayout, nextHidden) => {
const dto = nextLayout.map((l, idx) => ({
@@ -69,6 +91,7 @@ export default function Dashboard() {
return (
<div className="dashboard">
{syncing && <SyncSplash onDone={onSyncDone} />}
<div className="toolbar">
<div className="widget-toggles">
{ALL_WIDGETS.map((k) => (
+70
View File
@@ -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 &amp; automation.</span>
</footer>
</div>
);
}
+49
View File
@@ -61,3 +61,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.success { border-color: var(--ok); }
.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
View File
@@ -8,25 +8,25 @@ server {
location /api/ {
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-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header Cookie $http_cookie;
}
# Google OAuth2 callback + sign-out -> backend (same-origin session).
location ~ ^/(signin-google|signout-google) {
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-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header Cookie $http_cookie;
}
location / {
proxy_pass http://frontend_upstream;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
}
}
@@ -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")]
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")]
public async Task<IActionResult> Full(CancellationToken ct)
{
await _sync.RunFullSyncAsync(UserId, ct);
await _sync.QueueSyncAsync(UserId, fullSync: true, ct);
return Accepted();
}
/// <summary>Queues an incremental sync (becomes a full sync on first run).</summary>
[HttpPost("incremental")]
public async Task<IActionResult> Incremental(CancellationToken ct)
{
await _sync.RunIncrementalSyncAsync(UserId, ct);
await _sync.QueueSyncAsync(UserId, fullSync: false, ct);
return Accepted();
}
}
+2 -1
View File
@@ -1,7 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>InboxIntel.Api</RootNamespace>
<AssemblyName>InboxIntel.Api</AssemblyName>
<UserSecretsId>210c6d96-c7e4-4ee9-8982-8b91424979b8</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.7" />
+18 -1
View File
@@ -38,13 +38,30 @@ var google = builder.Configuration.GetSection(GoogleOAuthOptions.SectionName).Ge
builder.Services.AddAuthentication(options =>
{
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 =>
{
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.Name = "inboxintel.session";
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 =>
{
@@ -2,6 +2,12 @@
"DataProtection": {
"KeyPath": "./keys"
},
"App": {
"DevMode": true
},
"GmailSync": {
"MaxMessages": 1000
},
"Serilog": {
"MinimumLevel": {
"Default": "Debug"
+5 -1
View File
@@ -19,12 +19,16 @@
"https://www.googleapis.com/auth/gmail.modify"
]
},
"App": {
"DevMode": false
},
"GmailSync": {
"PageSize": 100,
"MaxParallelism": 4,
"MaxRetries": 5,
"BackoffBaseMs": 500,
"DailySyncHourUtc": 3
"DailySyncHourUtc": 3,
"MaxMessages": 0
},
"Ai": {
"Mode": "Disabled",
@@ -9,7 +9,11 @@ public interface ISyncService
{
Task RunFullSyncAsync(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);
/// <summary>Progress snapshot for the sync splash screen.</summary>
Task<DTOs.SyncProgressDto> GetProgressAsync(Guid userId, CancellationToken ct = default);
}
public interface IAnalyticsService
@@ -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);
}
@@ -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);
@@ -26,6 +26,11 @@ public class GmailSyncOptions
public int BackoffBaseMs { get; set; } = 500;
/// <summary>Cron-like daily sync hour (UTC) for the scheduled worker.</summary>
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
@@ -38,6 +38,11 @@ public static class DependencyInjection
services.AddScoped<GmailClientFactory>();
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
services.AddScoped<ISyncService, SyncService>();
services.AddScoped<IAnalyticsService, AnalyticsService>();
@@ -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.DTOs;
using InboxIntel.Domain.Entities;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Configuration;
using InboxIntel.Infrastructure.Gmail;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace InboxIntel.Infrastructure.Sync;
@@ -19,17 +22,46 @@ public class SyncService : ISyncService
private readonly AppDbContext _db;
private readonly IGmailService _gmail;
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;
_gmail = gmail;
_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)
=> (await GetOrCreateStateAsync(userId, ct)).Status;
public async Task<SyncProgressDto> GetProgressAsync(Guid userId, CancellationToken ct = default)
{
var s = await GetOrCreateStateAsync(userId, ct);
var total = _options.MaxMessages > 0
? Math.Min(s.TotalMessagesEstimate == 0 ? _options.MaxMessages : s.TotalMessagesEstimate, _options.MaxMessages)
: s.TotalMessagesEstimate;
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)
{
var state = await GetOrCreateStateAsync(userId, ct);
@@ -43,12 +75,21 @@ public class SyncService : ISyncService
{
await SyncLabelsAsync(userId, ct);
// Dev cap: stop after MaxMessages (most recent first). 0 = unlimited.
var maxMessages = _options.MaxMessages;
var capReached = false;
string? pageToken = state.ResumePageToken; // resume support
do
{
var page = await _gmail.ListMessageIdsAsync(userId, pageToken, ct);
foreach (var messageId in page.MessageIds)
{
if (maxMessages > 0 && state.MessagesProcessed >= maxMessages)
{
capReached = true;
break;
}
if (await _db.Emails.AnyAsync(e => e.UserId == userId && e.GmailMessageId == messageId, ct))
continue;
var detail = await _gmail.GetMessageAsync(userId, messageId, ct);
@@ -58,10 +99,12 @@ public class SyncService : ISyncService
pageToken = page.NextPageToken;
state.ResumePageToken = pageToken; // checkpoint
state.TotalMessagesEstimate = page.ResultSizeEstimate;
state.TotalMessagesEstimate = maxMessages > 0
? Math.Min(page.ResultSizeEstimate, maxMessages)
: page.ResultSizeEstimate;
await _db.SaveChangesAsync(ct);
}
while (pageToken is not null && !ct.IsCancellationRequested);
while (pageToken is not null && !capReached && !ct.IsCancellationRequested);
state.LastHistoryId = await _gmail.GetProfileHistoryIdAsync(userId, ct);
state.ResumePageToken = null;