From fe5920919f24762aada1153b08eec5ca7cb8e33b Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 30 Jun 2026 16:58:35 +0200 Subject: [PATCH] feat: landing page + logo, dev-mode banner + sync cap, non-blocking sync with progress splash --- .env.example | 5 ++ docker-compose.yml | 4 ++ frontend/index.html | 3 +- frontend/nginx.conf | 8 +-- frontend/public/favicon.svg | 12 ++++ frontend/src/api/client.js | 12 +++- frontend/src/components/DevBanner.jsx | 20 ++++++ frontend/src/components/Layout.jsx | 26 ++++--- frontend/src/components/Logo.jsx | 35 ++++++++++ frontend/src/components/SyncSplash.jsx | 60 ++++++++++++++++ frontend/src/main.jsx | 17 +++-- frontend/src/pages/Dashboard.jsx | 31 ++++++-- frontend/src/pages/Landing.jsx | 70 +++++++++++++++++++ frontend/src/styles.css | 49 +++++++++++++ nginx/nginx.conf | 10 +-- .../Controllers/AppInfoController.cs | 38 ++++++++++ .../Controllers/SyncController.cs | 9 +-- src/InboxIntel.Api/InboxIntel.Api.csproj | 3 +- src/InboxIntel.Api/Program.cs | 19 ++++- .../appsettings.Development.json | 6 ++ src/InboxIntel.Api/appsettings.json | 6 +- .../Abstractions/IServices.cs | 4 ++ .../Abstractions/ISyncQueue.cs | 10 +++ src/InboxIntel.Application/DTOs/SyncDtos.cs | 13 ++++ .../Configuration/Options.cs | 5 ++ .../DependencyInjection.cs | 5 ++ .../Sync/SyncQueue.cs | 18 +++++ .../Sync/SyncQueueWorker.cs | 44 ++++++++++++ .../Sync/SyncService.cs | 49 ++++++++++++- 29 files changed, 552 insertions(+), 39 deletions(-) create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/components/DevBanner.jsx create mode 100644 frontend/src/components/Logo.jsx create mode 100644 frontend/src/components/SyncSplash.jsx create mode 100644 frontend/src/pages/Landing.jsx create mode 100644 src/InboxIntel.Api/Controllers/AppInfoController.cs create mode 100644 src/InboxIntel.Application/Abstractions/ISyncQueue.cs create mode 100644 src/InboxIntel.Application/DTOs/SyncDtos.cs create mode 100644 src/InboxIntel.Infrastructure/Sync/SyncQueue.cs create mode 100644 src/InboxIntel.Infrastructure/Sync/SyncQueueWorker.cs diff --git a/.env.example b/.env.example index b3ade9a..7d4b748 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 975cdaa..217330c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/index.html b/frontend/index.html index 52eeb61..adcb792 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,8 +2,9 @@ + - InboxIntel + InboxIntel — Gmail analytics & cleanup
diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 0f6a95c..ed8ae7a 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; } } diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..fb1936b --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 12d5dcf..e91bf88 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -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') diff --git a/frontend/src/components/DevBanner.jsx b/frontend/src/components/DevBanner.jsx new file mode 100644 index 0000000..8fed53c --- /dev/null +++ b/frontend/src/components/DevBanner.jsx @@ -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 ( +
+ ⚠️ Dev / test mode ({info.environment}){cap}. Data and actions here are for testing. +
+ ); +} diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 6dee092..cf6b46e 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -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 (
+
-
📥 InboxIntel
+
{user?.email} +
diff --git a/frontend/src/components/Logo.jsx b/frontend/src/components/Logo.jsx new file mode 100644 index 0000000..95c4087 --- /dev/null +++ b/frontend/src/components/Logo.jsx @@ -0,0 +1,35 @@ +// InboxIntel mark: an inbox tray being "cleaned", with sparkles. +export default function Logo({ size = 32, withWordmark = false }) { + const mark = ( + + + + + + + + {/* rounded badge */} + + {/* inbox tray */} + + {/* tray opening line */} + + {/* cleaning sparkles */} + + + + ); + + if (!withWordmark) return mark; + + return ( + + {mark} + + InboxIntel + + + ); +} diff --git a/frontend/src/components/SyncSplash.jsx b/frontend/src/components/SyncSplash.jsx new file mode 100644 index 0000000..69df7c5 --- /dev/null +++ b/frontend/src/components/SyncSplash.jsx @@ -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 ( +
+
+ +

Loading your inbox…

+

Syncing emails from Gmail. This can take a little while on first run.

+ +
+
+
+ +
+ {total > 0 + ? `${processed.toLocaleString()} / ${total.toLocaleString()} emails${pct != null ? ` (${pct}%)` : ''}` + : `${processed.toLocaleString()} emails synced…`} +
+
+
+ ); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 83c65b8..1bee4fe 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -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( - }> - } /> - } /> - } /> + {/* Public landing page */} + } /> + + {/* Authenticated app */} + }> + } /> + } /> + } /> + + } /> diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 6ae3f5d..5bbfddf 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -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 (
+ {syncing && }
{ALL_WIDGETS.map((k) => ( diff --git a/frontend/src/pages/Landing.jsx b/frontend/src/pages/Landing.jsx new file mode 100644 index 0000000..7bf2f76 --- /dev/null +++ b/frontend/src/pages/Landing.jsx @@ -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 + ? + : Sign in with Google; + + return ( +
+
+ +
+ {user + ? + : Log in} +
+ +
+ +

Take back control of your inbox.

+

+ 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. +

+
{primaryCta}
+

Google sign-in only. Your tokens are encrypted at rest and never logged.

+
+ +
+ {FEATURES.map((f) => ( +
+
{f.icon}
+

{f.title}

+

{f.text}

+
+ ))} +
+ +
+

Ready to dig in?

+ {primaryCta} +
+ +
+ + Gmail analytics, cleanup & automation. +
+
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 395f52e..86b6f5a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; } diff --git a/nginx/nginx.conf b/nginx/nginx.conf index c1c7bb1..edd233a 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -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; } } diff --git a/src/InboxIntel.Api/Controllers/AppInfoController.cs b/src/InboxIntel.Api/Controllers/AppInfoController.cs new file mode 100644 index 0000000..8004246 --- /dev/null +++ b/src/InboxIntel.Api/Controllers/AppInfoController.cs @@ -0,0 +1,38 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace InboxIntel.Api.Controllers; + +/// +/// 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. +/// +[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("App:DevMode") ?? _env.IsDevelopment(); + return Ok(new + { + environment = _env.EnvironmentName, + devMode, + maxMessages = _config.GetValue("GmailSync:MaxMessages") + }); + } +} diff --git a/src/InboxIntel.Api/Controllers/SyncController.cs b/src/InboxIntel.Api/Controllers/SyncController.cs index 09c4a3e..a06ff02 100644 --- a/src/InboxIntel.Api/Controllers/SyncController.cs +++ b/src/InboxIntel.Api/Controllers/SyncController.cs @@ -10,20 +10,21 @@ public class SyncController : ApiControllerBase [HttpGet("status")] public async Task Status(CancellationToken ct) - => Ok(new { status = (await _sync.GetStatusAsync(UserId, ct)).ToString() }); + => Ok(await _sync.GetProgressAsync(UserId, ct)); - /// Triggers a full inbox sync (runs in the background task queue in production). + /// Queues a full inbox sync. Returns immediately; poll /sync/status for progress. [HttpPost("full")] public async Task Full(CancellationToken ct) { - await _sync.RunFullSyncAsync(UserId, ct); + await _sync.QueueSyncAsync(UserId, fullSync: true, ct); return Accepted(); } + /// Queues an incremental sync (becomes a full sync on first run). [HttpPost("incremental")] public async Task Incremental(CancellationToken ct) { - await _sync.RunIncrementalSyncAsync(UserId, ct); + await _sync.QueueSyncAsync(UserId, fullSync: false, ct); return Accepted(); } } diff --git a/src/InboxIntel.Api/InboxIntel.Api.csproj b/src/InboxIntel.Api/InboxIntel.Api.csproj index a0a8fd5..cf8de7e 100644 --- a/src/InboxIntel.Api/InboxIntel.Api.csproj +++ b/src/InboxIntel.Api/InboxIntel.Api.csproj @@ -1,7 +1,8 @@ - + InboxIntel.Api InboxIntel.Api + 210c6d96-c7e4-4ee9-8982-8b91424979b8 diff --git a/src/InboxIntel.Api/Program.cs b/src/InboxIntel.Api/Program.cs index d7a25b5..b5efc4b 100644 --- a/src/InboxIntel.Api/Program.cs +++ b/src/InboxIntel.Api/Program.cs @@ -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 => { diff --git a/src/InboxIntel.Api/appsettings.Development.json b/src/InboxIntel.Api/appsettings.Development.json index 5f4f713..d6e9d28 100644 --- a/src/InboxIntel.Api/appsettings.Development.json +++ b/src/InboxIntel.Api/appsettings.Development.json @@ -2,6 +2,12 @@ "DataProtection": { "KeyPath": "./keys" }, + "App": { + "DevMode": true + }, + "GmailSync": { + "MaxMessages": 1000 + }, "Serilog": { "MinimumLevel": { "Default": "Debug" diff --git a/src/InboxIntel.Api/appsettings.json b/src/InboxIntel.Api/appsettings.json index a42d44c..c189c77 100644 --- a/src/InboxIntel.Api/appsettings.json +++ b/src/InboxIntel.Api/appsettings.json @@ -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", diff --git a/src/InboxIntel.Application/Abstractions/IServices.cs b/src/InboxIntel.Application/Abstractions/IServices.cs index c09b5db..4538e44 100644 --- a/src/InboxIntel.Application/Abstractions/IServices.cs +++ b/src/InboxIntel.Application/Abstractions/IServices.cs @@ -9,7 +9,11 @@ public interface ISyncService { Task RunFullSyncAsync(Guid userId, CancellationToken ct = default); Task RunIncrementalSyncAsync(Guid userId, CancellationToken ct = default); + /// Marks the sync as starting and queues it for the background worker. + Task QueueSyncAsync(Guid userId, bool fullSync, CancellationToken ct = default); Task GetStatusAsync(Guid userId, CancellationToken ct = default); + /// Progress snapshot for the sync splash screen. + Task GetProgressAsync(Guid userId, CancellationToken ct = default); } public interface IAnalyticsService diff --git a/src/InboxIntel.Application/Abstractions/ISyncQueue.cs b/src/InboxIntel.Application/Abstractions/ISyncQueue.cs new file mode 100644 index 0000000..e37af56 --- /dev/null +++ b/src/InboxIntel.Application/Abstractions/ISyncQueue.cs @@ -0,0 +1,10 @@ +namespace InboxIntel.Application.Abstractions; + +/// +/// Hands sync work to a background worker so HTTP triggers return immediately +/// and the UI can poll progress (non-blocking architecture). +/// +public interface ISyncQueue +{ + void Enqueue(Guid userId, bool fullSync); +} diff --git a/src/InboxIntel.Application/DTOs/SyncDtos.cs b/src/InboxIntel.Application/DTOs/SyncDtos.cs new file mode 100644 index 0000000..7c7b33f --- /dev/null +++ b/src/InboxIntel.Application/DTOs/SyncDtos.cs @@ -0,0 +1,13 @@ +namespace InboxIntel.Application.DTOs; + +/// +/// Live sync progress for the splash screen. reflects the +/// dev cap when one is configured, so the progress bar fills correctly. +/// +public record SyncProgressDto( + string Status, + int Processed, + int Total, + bool IsRunning, + DateTimeOffset? LastSuccessfulSyncUtc, + string? LastError); diff --git a/src/InboxIntel.Infrastructure/Configuration/Options.cs b/src/InboxIntel.Infrastructure/Configuration/Options.cs index 7676b9c..808eac8 100644 --- a/src/InboxIntel.Infrastructure/Configuration/Options.cs +++ b/src/InboxIntel.Infrastructure/Configuration/Options.cs @@ -26,6 +26,11 @@ public class GmailSyncOptions public int BackoffBaseMs { get; set; } = 500; /// Cron-like daily sync hour (UTC) for the scheduled worker. public int DailySyncHourUtc { get; set; } = 3; + /// + /// 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. + /// + public int MaxMessages { get; set; } = 0; } public class AiOptions diff --git a/src/InboxIntel.Infrastructure/DependencyInjection.cs b/src/InboxIntel.Infrastructure/DependencyInjection.cs index 0e5b35e..3898679 100644 --- a/src/InboxIntel.Infrastructure/DependencyInjection.cs +++ b/src/InboxIntel.Infrastructure/DependencyInjection.cs @@ -38,6 +38,11 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); + // Background sync queue (singleton) + its worker. + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddHostedService(); + // Core services services.AddScoped(); services.AddScoped(); diff --git a/src/InboxIntel.Infrastructure/Sync/SyncQueue.cs b/src/InboxIntel.Infrastructure/Sync/SyncQueue.cs new file mode 100644 index 0000000..63849f0 --- /dev/null +++ b/src/InboxIntel.Infrastructure/Sync/SyncQueue.cs @@ -0,0 +1,18 @@ +using System.Threading.Channels; +using InboxIntel.Application.Abstractions; + +namespace InboxIntel.Infrastructure.Sync; + +/// +/// In-process unbounded queue of sync jobs, backed by a Channel. Registered as +/// a singleton; the controller writes, reads. +/// +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; +} diff --git a/src/InboxIntel.Infrastructure/Sync/SyncQueueWorker.cs b/src/InboxIntel.Infrastructure/Sync/SyncQueueWorker.cs new file mode 100644 index 0000000..c844cc7 --- /dev/null +++ b/src/InboxIntel.Infrastructure/Sync/SyncQueueWorker.cs @@ -0,0 +1,44 @@ +using InboxIntel.Application.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace InboxIntel.Infrastructure.Sync; + +/// +/// Drains the and runs each sync in its own DI scope, +/// then refreshes analytics aggregates. Keeps sync work off the request thread. +/// +public class SyncQueueWorker : BackgroundService +{ + private readonly SyncQueue _queue; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public SyncQueueWorker(SyncQueue queue, IServiceScopeFactory scopeFactory, ILogger 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(); + var analytics = scope.ServiceProvider.GetRequiredService(); + 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); + } + } + } +} diff --git a/src/InboxIntel.Infrastructure/Sync/SyncService.cs b/src/InboxIntel.Infrastructure/Sync/SyncService.cs index ecdc854..ed2f549 100644 --- a/src/InboxIntel.Infrastructure/Sync/SyncService.cs +++ b/src/InboxIntel.Infrastructure/Sync/SyncService.cs @@ -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 _logger; + private readonly GmailSyncOptions _options; + private readonly ISyncQueue _queue; - public SyncService(AppDbContext db, IGmailService gmail, ILogger logger) + public SyncService(AppDbContext db, IGmailService gmail, ILogger logger, IOptions 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 GetStatusAsync(Guid userId, CancellationToken ct = default) => (await GetOrCreateStateAsync(userId, ct)).Status; + public async Task 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;