commit f43ef5f945471fe8b59a1e5df750b5582b44e517 Author: cesnimda Date: Tue Jun 30 15:53:32 2026 +0200 chore: init project diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b3ade9a --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Copy to .env and fill in. docker-compose reads these. +POSTGRES_PASSWORD=change-me + +# Google OAuth2 credentials (create at https://console.cloud.google.com/apis/credentials) +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# AI mode: Disabled | LocalOllama | CloudOpenAi +AI_MODE=Disabled + +# Origin the API allows for CORS (the frontend container). +FRONTEND_ORIGIN=http://localhost:8081 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec071ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,68 @@ +## .NET +bin/ +obj/ +*.user +*.suo +.vs/ +artifacts/ + +## Rider / VS +.idea/ + +## Node / Vite +node_modules/ +frontend/dist/ +frontend/.vite/ + +## Env & secrets +.env +.env.local +*.pfx +appsettings.*.local.json +secrets.json + +## Logs +logs/ +*.log + +## OS +.DS_Store +Thumbs.db + +# ── GSD baseline (auto-generated) ── +.gsd +.gsd-worktrees/ +.gsd-id +.mcp.json +.bg-shell/ +nul +nul.* +con +con.* +prn +prn.* +aux +aux.* +com[1-9] +com[1-9].* +lpt[1-9] +lpt[1-9].* +*.swp +*.swo +*~ +.vscode/ +*.code-workspace +.env.* +!.env.example +.next/ +dist/ +build/ +__pycache__/ +*.pyc +.venv/ +venv/ +target/ +vendor/ +coverage/ +.cache/ +tmp/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..626a974 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,11 @@ + + + net8.0 + enable + enable + latest + false + false + false + + diff --git a/InboxIntel.sln b/InboxIntel.sln new file mode 100644 index 0000000..2dc1707 --- /dev/null +++ b/InboxIntel.sln @@ -0,0 +1,48 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.0.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InboxIntel.Domain", "src\InboxIntel.Domain\InboxIntel.Domain.csproj", "{11111111-1111-1111-1111-111111111111}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InboxIntel.Application", "src\InboxIntel.Application\InboxIntel.Application.csproj", "{22222222-2222-2222-2222-222222222222}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InboxIntel.Infrastructure", "src\InboxIntel.Infrastructure\InboxIntel.Infrastructure.csproj", "{33333333-3333-3333-3333-333333333333}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InboxIntel.Api", "src\InboxIntel.Api\InboxIntel.Api.csproj", "{44444444-4444-4444-4444-444444444444}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InboxIntel.UnitTests", "tests\InboxIntel.UnitTests\InboxIntel.UnitTests.csproj", "{55555555-5555-5555-5555-555555555555}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InboxIntel.IntegrationTests", "tests\InboxIntel.IntegrationTests\InboxIntel.IntegrationTests.csproj", "{66666666-6666-6666-6666-666666666666}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {11111111-1111-1111-1111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11111111-1111-1111-1111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11111111-1111-1111-1111-111111111111}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11111111-1111-1111-1111-111111111111}.Release|Any CPU.Build.0 = Release|Any CPU + {22222222-2222-2222-2222-222222222222}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {22222222-2222-2222-2222-222222222222}.Debug|Any CPU.Build.0 = Debug|Any CPU + {22222222-2222-2222-2222-222222222222}.Release|Any CPU.ActiveCfg = Release|Any CPU + {22222222-2222-2222-2222-222222222222}.Release|Any CPU.Build.0 = Release|Any CPU + {33333333-3333-3333-3333-333333333333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {33333333-3333-3333-3333-333333333333}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33333333-3333-3333-3333-333333333333}.Release|Any CPU.ActiveCfg = Release|Any CPU + {33333333-3333-3333-3333-333333333333}.Release|Any CPU.Build.0 = Release|Any CPU + {44444444-4444-4444-4444-444444444444}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44444444-4444-4444-4444-444444444444}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44444444-4444-4444-4444-444444444444}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44444444-4444-4444-4444-444444444444}.Release|Any CPU.Build.0 = Release|Any CPU + {55555555-5555-5555-5555-555555555555}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {55555555-5555-5555-5555-555555555555}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55555555-5555-5555-5555-555555555555}.Release|Any CPU.ActiveCfg = Release|Any CPU + {55555555-5555-5555-5555-555555555555}.Release|Any CPU.Build.0 = Release|Any CPU + {66666666-6666-6666-6666-666666666666}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {66666666-6666-6666-6666-666666666666}.Debug|Any CPU.Build.0 = Debug|Any CPU + {66666666-6666-6666-6666-666666666666}.Release|Any CPU.ActiveCfg = Release|Any CPU + {66666666-6666-6666-6666-666666666666}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..97c2315 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# InboxIntel — Inbox Intelligence Platform + +A Gmail analytics, cleanup, and automation app. Connects via Google OAuth2, syncs your inbox into PostgreSQL, and gives you a draggable analytics dashboard, safe bulk cleanup, unsubscribe management, optional AI analysis, advanced search, and PDF/CSV/JSON exports. + +This repository is a full-stack **scaffold** structured for the 15-step implementation plan. Every layer is wired and compiles; the business logic is functional, with a few integration points (Gmail parsing edge cases, AI prompt tuning) intentionally left as clearly-marked extension points. + +## Architecture + +Clean Architecture across four backend projects plus a React SPA: + +``` +src/ + InboxIntel.Domain Entities + enums. No external dependencies. + InboxIntel.Application Service interfaces, DTOs, validators, query parser. + InboxIntel.Infrastructure EF Core, Gmail client, sync worker, AI, export. + InboxIntel.Api ASP.NET Core Web API: auth, controllers, DI, Serilog. +frontend/ React + Vite dashboard (Chart.js, react-grid-layout). +tests/ Unit + integration test projects. +``` + +Dependency rule: `Api -> Infrastructure -> Application -> Domain`. Controllers contain no business logic; they delegate to Application-layer service interfaces resolved through DI. + +See `docs/ARCHITECTURE.md` for the full design, data model, and request flow. + +## Tech stack + +.NET 8 / ASP.NET Core, EF Core + Npgsql (PostgreSQL), Hosted background worker, Serilog, FluentValidation, Polly (retry + backoff), Google.Apis.Gmail, QuestPDF/CsvHelper for exports, React 18 + Vite + Chart.js + react-grid-layout. + +## Running with Docker (recommended) + +```bash +cp .env.example .env # then fill in Google OAuth credentials +docker compose up --build +``` + +Services: PostgreSQL (5432), API (8080), frontend (8081). Optional reverse proxy: + +```bash +docker compose --profile proxy up --build # everything on port 80 +``` + +The API applies EF migrations automatically on startup (`Database:AutoMigrate`). + +## Running locally (without Docker) + +1. Start PostgreSQL and set the connection string in `src/InboxIntel.Api/appsettings.Development.json`. +2. Create the initial migration and database: + + ```bash + dotnet tool install --global dotnet-ef + dotnet ef migrations add InitialCreate \ + -p src/InboxIntel.Infrastructure -s src/InboxIntel.Api + dotnet ef database update -p src/InboxIntel.Infrastructure -s src/InboxIntel.Api + ``` + +3. Run the API and the frontend: + + ```bash + dotnet run --project src/InboxIntel.Api # http://localhost:5080 + cd frontend && npm install && npm run dev # http://localhost:5173 + ``` + +## Google OAuth2 setup + +Create an OAuth client (type: Web application) in the Google Cloud Console. Add the Gmail API. Authorized redirect URI: `http://localhost:5080/signin-google` (dev) and your production URL. Put the client id/secret in `.env` or user-secrets. Scopes requested: `openid email profile gmail.readonly gmail.modify` (no send scope). + +## Security notes + +OAuth refresh tokens are encrypted at rest with the ASP.NET Core Data Protection API (AES) and never logged. Keys persist to a mounted `/keys` volume. All destructive cleanup and unsubscribe actions require an explicit `Confirmed` flag and a server-side preview. The AI layer is advisory only — it never performs destructive actions. + +## Tests + +```bash +dotnet test +``` + +Unit tests cover the Gmail query parser and unsubscribe extraction; integration tests boot the API host and assert authorization is enforced. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..975cdaa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: inboxintel + POSTGRES_USER: inboxintel + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-inboxintel} + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U inboxintel"] + interval: 5s + timeout: 5s + retries: 10 + + api: + build: + context: . + dockerfile: src/InboxIntel.Api/Dockerfile + environment: + ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_URLS: http://+:8080 + ConnectionStrings__Postgres: "Host=postgres;Port=5432;Database=inboxintel;Username=inboxintel;Password=${POSTGRES_PASSWORD:-inboxintel}" + DataProtection__KeyPath: /keys + GoogleOAuth__ClientId: ${GOOGLE_CLIENT_ID:-} + GoogleOAuth__ClientSecret: ${GOOGLE_CLIENT_SECRET:-} + Ai__Mode: ${AI_MODE:-Disabled} + Cors__Origins__0: ${FRONTEND_ORIGIN:-http://localhost:8081} + volumes: + - keys:/keys + depends_on: + postgres: + condition: service_healthy + ports: + - "8080:8080" + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + depends_on: + - api + ports: + - "8081:80" + + # Optional reverse proxy. Enable with: docker compose --profile proxy up + nginx: + image: nginx:alpine + profiles: ["proxy"] + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - api + - frontend + ports: + - "80:80" + +volumes: + pgdata: + keys: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..205ba26 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,45 @@ +# InboxIntel Architecture + +## Layering (Clean Architecture) + +The solution enforces a one-directional dependency flow so the core stays testable and framework-agnostic. + +`InboxIntel.Domain` holds entities (`Email`, `MailThread`, `Sender`, `MailDomain`, `Attachment`, `Label`/`EmailLabel`, `SyncState`, `AnalyticsAggregate`, `WidgetLayout`, `UnsubscribeItem`, `User`) and enums. It references nothing except Npgsql (for the `NpgsqlTsVector` full-text type). + +`InboxIntel.Application` defines the contracts the rest of the system programs against: `IGmailService`, `ISyncService`, `IAnalyticsService`, `ISearchService`, `ICleanupService`, `IUnsubscribeService`, `IAiService`/`IAiProvider`, `IExportService`, plus `IAppDbContext`, DTOs, FluentValidation validators, and the `GmailQueryParser`. + +`InboxIntel.Infrastructure` implements those contracts: the EF Core `AppDbContext` and entity configurations, the Gmail REST client (`GmailApiService` + `GmailClientFactory` + `GmailMessageParser`), the `SyncService` and `GmailSyncWorker`, analytics/search/cleanup/unsubscribe services, the AI providers (Null/Ollama/OpenAI) and `AiService`, the `ExportService` (PDF/CSV/JSON), and the `DataProtectionTokenProtector`. + +`InboxIntel.Api` is the composition root: Serilog, Google OAuth2 + cookie auth, API versioning, CORS, controllers, and startup migration. + +## Request flow + +A browser calls `/api/v1/...` with the auth cookie. The controller (no business logic) resolves `ICurrentUser` to get the tenant id and calls an Application interface. The Infrastructure implementation runs EF Core queries / Gmail calls and returns DTOs. Validators run via the FluentValidation pipeline before handlers execute. + +## Authentication + +Google OAuth2 is the only login method. The Google handler runs with `AccessType=offline` to obtain a refresh token; `GoogleAuthEvents.OnCreatingTicketAsync` upserts the `User`, encrypts the refresh token with the Data Protection API, and stamps the internal user id (`inboxintel:uid`) as a claim. A 7-day cookie carries the session. `GmailClientFactory` decrypts the refresh token per call and lets the Google client library refresh access tokens automatically. + +## Data model & scale + +Every owned row carries a `UserId` (multi-user ready though single-user today). Composite indexes back the hot paths at 100k+ emails: `(UserId, GmailMessageId)` unique, `(UserId, SenderId)`, `(UserId, SentAtUtc)`, `(UserId, IsUnread)`, `(UserId, Category)`. Sender/domain rollup counters are maintained during sync for instant grouping. Full-text search uses a stored, generated `tsvector` column over subject+body with a GIN index, queried through `EF.Functions.PlainToTsQuery`. Daily `AnalyticsAggregate` rows let dashboard widgets render without scanning the email table. + +## Gmail sync + +Full sync pages every message id, fetches+parses each, and checkpoints the page token and counts to `SyncState` after each page — so an interrupted run resumes instead of restarting. Incremental sync replays Gmail's history feed from the stored `historyId` watermark, applying adds and deletes. All Gmail calls run through a Polly pipeline: exponential backoff with jitter on 429/5xx, capped by `GmailSync:MaxRetries`. A `BackgroundService` (`GmailSyncWorker`) runs a daily incremental sync per user and refreshes aggregates, fully off the request path. In production this can be swapped for Hangfire without touching callers. + +## Cleanup & unsubscribe safety + +`CleanupService.PreviewAsync` always precedes execution and returns the affected count, total size, and a sample. Destructive actions (Trash, HardDelete) are rejected unless `Confirmed == true` — enforced both by a validator and again inside the service as defence in depth. The unsubscribe pipeline detects `List-Unsubscribe` headers during sync, groups opportunities per sender, ranks them by volume, and only processes a user-confirmed queue. One-click (`List-Unsubscribe-Post`) targets are POSTed; `mailto:` targets are surfaced for the user — the app never auto-sends mail. + +## AI layer + +Toggleable via `Ai:Mode` (`Disabled` / `LocalOllama` / `CloudOpenAi`), selected at DI time. `IAiProvider` abstracts chat completion; `AiService` builds classification, inbox summaries, cleanup suggestions, and natural-language→Gmail-query features on top. Critically, the AI layer only reads and suggests — destructive actions always route back through the confirmed cleanup/unsubscribe flows. + +## Frontend + +React + Vite SPA. The dashboard uses `react-grid-layout` for draggable/resizable widgets with hide/show toggles; the layout is persisted per user via `PUT /widgetlayout`. Widgets render through Chart.js (volume line, attachment doughnut) and a custom CSS heatmap. The axios client sends the session cookie and redirects to the Google login flow on 401. + +## Extension points (left intentionally open) + +Gmail MIME parsing handles the common multipart/plain cases; richer HTML-body unsubscribe-URL scraping and attachment-by-attachment download are stubbed for extension. AI prompts are minimal and meant to be tuned. The background worker uses a simple hourly tick; production deployments may prefer Hangfire with a cron schedule. diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..4e6451c --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +# Backend API target for the Vite dev proxy. +VITE_API_TARGET=http://localhost:5080 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..9bb3ee0 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +# Build the Vite app, then serve the static bundle with nginx. +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..52eeb61 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + InboxIntel + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..65af464 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # SPA fallback. + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API + auth calls to the backend container. + location /api/ { + proxy_pass http://api:8080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Cookie $http_cookie; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..897f367 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "inboxintel-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview --host" + }, + "dependencies": { + "axios": "^1.7.2", + "chart.js": "^4.4.3", + "react": "^18.3.1", + "react-chartjs-2": "^5.2.0", + "react-dom": "^18.3.1", + "react-grid-layout": "^1.4.4", + "react-router-dom": "^6.24.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.3.1" + } +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..12d5dcf --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,57 @@ +import axios from 'axios'; + +// Cookie-based auth (Google OAuth2 session), so send credentials with each call. +const api = axios.create({ baseURL: '/api/v1', withCredentials: true }); + +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=/'; + } + return Promise.reject(err); + } +); + +export const AuthApi = { + me: () => api.get('/auth/me').then((r) => r.data), + logout: () => api.post('/auth/logout') +}; + +export const SyncApi = { + status: () => api.get('/sync/status').then((r) => r.data), + full: () => api.post('/sync/full'), + incremental: () => api.post('/sync/incremental') +}; + +export const AnalyticsApi = { + dashboard: () => api.get('/analytics/dashboard').then((r) => r.data), + health: () => api.get('/analytics/health').then((r) => r.data), + topSenders: (take = 20) => api.get(`/analytics/top-senders?take=${take}`).then((r) => r.data), + volume: (days = 90) => api.get(`/analytics/volume?days=${days}`).then((r) => r.data), + heatmap: () => api.get('/analytics/heatmap').then((r) => r.data), + attachments: () => api.get('/analytics/attachments').then((r) => r.data) +}; + +export const CleanupApi = { + preview: (req) => api.post('/cleanup/preview', req).then((r) => r.data), + execute: (req) => api.post('/cleanup/execute', req).then((r) => r.data) +}; + +export const UnsubscribeApi = { + detect: () => api.post('/unsubscribe/detect'), + safeList: () => api.get('/unsubscribe/safe-list').then((r) => r.data), + process: (req) => api.post('/unsubscribe/process', req).then((r) => r.data) +}; + +export const LayoutApi = { + get: () => api.get('/widgetlayout').then((r) => r.data), + save: (layout) => api.put('/widgetlayout', layout) +}; + +export const ExportApi = { + reportUrl: (format) => `/api/v1/export/report?format=${format}` +}; + +export default api; diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100644 index 0000000..6dee092 --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -0,0 +1,39 @@ +import { Link, Outlet, useLocation } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { AuthApi, SyncApi } from '../api/client.js'; + +export default function Layout() { + const [user, setUser] = useState(null); + const loc = useLocation(); + + useEffect(() => { + AuthApi.me().then(setUser).catch(() => {}); + }, []); + + const nav = [ + { to: '/', label: 'Dashboard' }, + { to: '/cleanup', label: 'Cleanup' }, + { to: '/unsubscribe', label: 'Unsubscribe' } + ]; + + return ( +
+
+
📥 InboxIntel
+ +
+ + {user?.email} +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/widgets.jsx b/frontend/src/components/widgets.jsx new file mode 100644 index 0000000..0fb7de5 --- /dev/null +++ b/frontend/src/components/widgets.jsx @@ -0,0 +1,113 @@ +import { Bar, Line, Doughnut } from 'react-chartjs-2'; +import { + Chart as ChartJS, CategoryScale, LinearScale, BarElement, PointElement, + LineElement, ArcElement, Tooltip, Legend +} from 'chart.js'; + +ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, ArcElement, Tooltip, Legend); + +const fmtBytes = (b) => { + if (!b) return '0 B'; + const u = ['B', 'KB', 'MB', 'GB']; let i = 0; let n = b; + while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } + return `${n.toFixed(1)} ${u[i]}`; +}; + +export function StatCard({ label, value }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +export function HealthWidget({ health }) { + if (!health) return ; + return ( +
+

Inbox Health

+
{health.score}/100
+
Grade {health.grade}
+
    {health.recommendations.map((r, i) =>
  • {r}
  • )}
+
+ ); +} + +export function TopSendersWidget({ senders }) { + if (!senders) return ; + return ( +
+

Top Senders

+ + + {senders.map((s) => ( + + + + + ))} + +
{s.displayName || s.address}{s.emailCount}
+
+ ); +} + +export function VolumeWidget({ volume }) { + if (!volume) return ; + const data = { + labels: volume.map((p) => p.day), + datasets: [{ label: 'Emails', data: volume.map((p) => p.count), borderColor: '#4f8cff', tension: 0.3 }] + }; + return ( +
+

Email Volume

+ +
+ ); +} + +export function HeatmapWidget({ heatmap }) { + if (!heatmap) return ; + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const max = Math.max(1, ...heatmap.map((c) => c.count)); + const grid = {}; + heatmap.forEach((c) => { grid[`${c.dayOfWeek}-${c.hour}`] = c.count; }); + return ( +
+

Activity Heatmap

+
+ {days.map((d, dow) => ( +
+ {d} + {Array.from({ length: 24 }, (_, h) => { + const v = grid[`${dow}-${h}`] || 0; + return ; + })} +
+ ))} +
+
+ ); +} + +export function AttachmentsWidget({ attachments }) { + if (!attachments) return ; + const data = { + labels: attachments.map((a) => a.mimeBucket), + datasets: [{ data: attachments.map((a) => a.totalBytes), backgroundColor: ['#4f8cff', '#6fcf97', '#f2c94c', '#eb5757', '#bb6bd9', '#56ccf2', '#a0a0a0'] }] + }; + return ( +
+

Attachments by Type

+ +
Total: {fmtBytes(attachments.reduce((s, a) => s + a.totalBytes, 0))}
+
+ ); +} + +export function StorageWidget({ bytes }) { + return ; +} + +function Empty() { return
No data yet — run a sync.
; } diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..83c65b8 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import Dashboard from './pages/Dashboard.jsx'; +import Cleanup from './pages/Cleanup.jsx'; +import Unsubscribe from './pages/Unsubscribe.jsx'; +import Layout from './components/Layout.jsx'; +import './styles.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + }> + } /> + } /> + } /> + + + + +); diff --git a/frontend/src/pages/Cleanup.jsx b/frontend/src/pages/Cleanup.jsx new file mode 100644 index 0000000..38352f5 --- /dev/null +++ b/frontend/src/pages/Cleanup.jsx @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import { CleanupApi } from '../api/client.js'; + +const ACTIONS = [ + { v: 0, label: 'Archive' }, + { v: 1, label: 'Trash' }, + { v: 5, label: 'Mark read' }, + { v: 6, label: 'Mark unread' } +]; + +// Mirrors CleanupActionType: 1 = Trash, 2 = HardDelete are destructive. +const isDestructive = (a) => a === 1 || a === 2; + +export default function Cleanup() { + const [query, setQuery] = useState('from:newsletter is:read'); + const [action, setAction] = useState(0); + const [preview, setPreview] = useState(null); + const [result, setResult] = useState(null); + const [busy, setBusy] = useState(false); + + const doPreview = async () => { + setResult(null); setBusy(true); + try { setPreview(await CleanupApi.preview({ action, query, emailIds: null, labelId: null, confirmed: false })); } + finally { setBusy(false); } + }; + + const doExecute = async () => { + if (isDestructive(action) && !window.confirm(`This will ${ACTIONS.find((a) => a.v === action)?.label} ${preview?.affectedCount} emails. Continue?`)) return; + setBusy(true); + try { + const res = await CleanupApi.execute({ action, query, emailIds: null, labelId: null, confirmed: true }); + setResult(res); setPreview(null); + } finally { setBusy(false); } + }; + + return ( +
+

Bulk Cleanup

+

Preview is required before any action runs. Destructive actions ask for confirmation.

+ +
+ setQuery(e.target.value)} placeholder="Gmail-like query, e.g. from:github.com is:read" /> + + +
+ + {preview && ( +
+ {preview.affectedCount} emails match + ({(preview.affectedSizeBytes / 1048576).toFixed(1)} MB). +
    {preview.sample.map((e) =>
  • {e.subject} — {e.senderAddress}
  • )}
+ +
+ )} + + {result &&
Done: {result.succeededCount} succeeded, {result.failedCount} failed.
} +
+ ); +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..6ae3f5d --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,101 @@ +import { 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 { + HealthWidget, StatCard, TopSendersWidget, VolumeWidget, + HeatmapWidget, AttachmentsWidget, StorageWidget +} from '../components/widgets.jsx'; + +// Default grid geometry; overridden by the user's saved layout. +const DEFAULT_LAYOUT = [ + { i: 'inbox-health', x: 0, y: 0, w: 3, h: 5 }, + { i: 'total-emails', x: 3, y: 0, w: 2, h: 2 }, + { i: 'unread-emails', x: 5, 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: 'volume', x: 6, y: 2, w: 6, h: 4 }, + { i: 'heatmap', x: 0, y: 5, w: 6, h: 4 }, + { i: 'attachments', x: 6, y: 6, w: 4, h: 4 } +]; + +const ALL_WIDGETS = DEFAULT_LAYOUT.map((l) => l.i); + +export default function Dashboard() { + const [data, setData] = useState(null); + const [layout, setLayout] = useState(DEFAULT_LAYOUT); + const [hidden, setHidden] = useState([]); + + 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(() => {}); + }, []); + + const persist = (nextLayout, nextHidden) => { + const dto = nextLayout.map((l, idx) => ({ + widgetKey: l.i, x: l.x, y: l.y, w: l.w, h: l.h, + visible: !nextHidden.includes(l.i), sortOrder: idx, settingsJson: null + })); + LayoutApi.save(dto).catch(() => {}); + }; + + const onLayoutChange = (l) => { setLayout(l); persist(l, hidden); }; + const toggle = (key) => { + const next = hidden.includes(key) ? hidden.filter((h) => h !== key) : [...hidden, key]; + setHidden(next); persist(layout, next); + }; + + const visibleLayout = useMemo(() => layout.filter((l) => !hidden.includes(l.i)), [layout, hidden]); + + const render = (key) => { + switch (key) { + case 'inbox-health': return ; + case 'total-emails': return ; + case 'unread-emails': return ; + case 'storage': return ; + case 'top-senders': return ; + case 'volume': return ; + case 'heatmap': return ; + case 'attachments': return ; + default: return null; + } + }; + + return ( +
+
+
+ {ALL_WIDGETS.map((k) => ( + + ))} +
+
+ Export PDF + CSV + JSON +
+ + + {visibleLayout.map((l) => ( +
{render(l.i)}
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/Unsubscribe.jsx b/frontend/src/pages/Unsubscribe.jsx new file mode 100644 index 0000000..413f6cd --- /dev/null +++ b/frontend/src/pages/Unsubscribe.jsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; +import { UnsubscribeApi } from '../api/client.js'; + +const METHOD = ['None', 'HTTP link', 'mailto', 'One-click']; +const STATUS = ['Detected', 'Queued', 'In progress', 'Succeeded', 'Failed', 'Skipped']; + +export default function Unsubscribe() { + const [items, setItems] = useState([]); + const [selected, setSelected] = useState({}); + const [busy, setBusy] = useState(false); + + const load = async () => setItems(await UnsubscribeApi.safeList()); + useEffect(() => { load(); }, []); + + const detect = async () => { setBusy(true); try { await UnsubscribeApi.detect(); await load(); } finally { setBusy(false); } }; + + const process = async () => { + const ids = Object.keys(selected).filter((k) => selected[k]); + if (!ids.length) return; + if (!window.confirm(`Unsubscribe from ${ids.length} sender(s)?`)) return; + setBusy(true); + try { await UnsubscribeApi.process({ itemIds: ids, confirmed: true }); await load(); setSelected({}); } + finally { setBusy(false); } + }; + + return ( +
+

Unsubscribe Manager

+
+ + +
+ + + + {items.map((it) => ( + + + + + + + + + ))} + {!items.length && } + +
SenderDomainMethodEmailsStatus
setSelected({ ...selected, [it.id]: e.target.checked })} />{it.senderAddress}{it.domain}{METHOD[it.method]}{it.emailCount}{STATUS[it.status]}
Nothing detected yet. Run a scan.
+
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..395f52e --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,63 @@ +:root { + --bg: #0f1420; + --panel: #1a2030; + --panel-2: #222a3d; + --text: #e6e9f0; + --muted: #8b93a7; + --accent: #4f8cff; + --danger: #eb5757; + --ok: #6fcf97; +} + +* { box-sizing: border-box; } +body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); } + +.topbar { display: flex; align-items: center; gap: 18px; padding: 12px 20px; background: var(--panel); border-bottom: 1px solid #2c3550; } +.brand { font-weight: 700; font-size: 18px; } +.topbar nav a { color: var(--muted); text-decoration: none; margin-right: 14px; } +.topbar nav a.active, .topbar nav a:hover { color: var(--text); } +.spacer { flex: 1; } +.user { color: var(--muted); font-size: 13px; } + +main { padding: 20px; } + +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:disabled { opacity: 0.5; cursor: default; } + +.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; } +.widget-toggles { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--muted); } + +.grid-item { background: var(--panel); border: 1px solid #2c3550; border-radius: 10px; overflow: hidden; } +.widget { padding: 14px; height: 100%; display: flex; flex-direction: column; } +.widget h3 { margin: 0 0 10px; font-size: 14px; cursor: move; } +.widget canvas { flex: 1; min-height: 0; } + +.stat { align-items: flex-start; justify-content: center; } +.stat-value { font-size: 34px; font-weight: 700; } +.stat-label { color: var(--muted); font-size: 13px; cursor: move; } + +.health-score { font-size: 46px; font-weight: 800; } +.health-score span { font-size: 18px; color: var(--muted); } +.grade-A { color: var(--ok); } .grade-B { color: #9bdf6f; } .grade-C { color: #f2c94c; } +.grade-D { color: #f2994a; } .grade-F { color: var(--danger); } +.recs { margin: 8px 0 0; padding-left: 16px; font-size: 12px; color: var(--muted); } + +table.mini, table.grid { width: 100%; border-collapse: collapse; font-size: 13px; } +table.mini td { padding: 3px 0; } +table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; text-align: left; } +.num { text-align: right; } +.muted { color: var(--muted); font-size: 12px; } + +.heatmap { display: flex; flex-direction: column; gap: 2px; } +.hm-row { display: flex; align-items: center; gap: 2px; } +.hm-day { width: 30px; font-size: 10px; color: var(--muted); } +.hm-cell { width: 10px; height: 10px; background: var(--accent); border-radius: 2px; } + +.page { max-width: 900px; } +.form-row { display: flex; gap: 10px; margin: 14px 0; } +.form-row input { flex: 1; } +input, select { background: var(--panel-2); border: 1px solid #2c3550; color: var(--text); border-radius: 6px; padding: 8px; } +.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); } diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..25fd541 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + // Proxy API calls to the .NET backend during development. + '/api': { + target: process.env.VITE_API_TARGET || 'http://localhost:5080', + changeOrigin: true + } + } + } +}); diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..9966300 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,21 @@ +# Optional top-level reverse proxy fronting both the SPA and the API on port 80. +upstream api_upstream { server api:8080; } +upstream frontend_upstream { server frontend:80; } + +server { + listen 80; + server_name _; + + location /api/ { + proxy_pass http://api_upstream; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Cookie $http_cookie; + } + + location / { + proxy_pass http://frontend_upstream; + proxy_set_header Host $host; + } +} diff --git a/src/InboxIntel.Api/Auth/CurrentUser.cs b/src/InboxIntel.Api/Auth/CurrentUser.cs new file mode 100644 index 0000000..a45d505 --- /dev/null +++ b/src/InboxIntel.Api/Auth/CurrentUser.cs @@ -0,0 +1,22 @@ +using System.Security.Claims; +using InboxIntel.Application.Abstractions; + +namespace InboxIntel.Api.Auth; + +/// Resolves the authenticated user's id from the cookie principal. +public class CurrentUser : ICurrentUser +{ + private readonly IHttpContextAccessor _accessor; + public CurrentUser(IHttpContextAccessor accessor) => _accessor = accessor; + + public bool IsAuthenticated => _accessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false; + + public Guid UserId + { + get + { + var raw = _accessor.HttpContext?.User?.FindFirstValue("inboxintel:uid"); + return Guid.TryParse(raw, out var id) ? id : Guid.Empty; + } + } +} diff --git a/src/InboxIntel.Api/Auth/GoogleAuthEvents.cs b/src/InboxIntel.Api/Auth/GoogleAuthEvents.cs new file mode 100644 index 0000000..54d7aa9 --- /dev/null +++ b/src/InboxIntel.Api/Auth/GoogleAuthEvents.cs @@ -0,0 +1,54 @@ +using System.Security.Claims; +using InboxIntel.Application.Abstractions; +using InboxIntel.Domain.Entities; +using InboxIntel.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.EntityFrameworkCore; + +namespace InboxIntel.Api.Auth; + +/// +/// On successful Google sign-in: upsert the user, encrypt and persist the +/// refresh token, and attach the internal user id as a claim so downstream +/// requests resolve the correct tenant. +/// +public static class GoogleAuthEvents +{ + public static async Task OnCreatingTicketAsync(OAuthCreatingTicketContext context) + { + var sp = context.HttpContext.RequestServices; + var db = sp.GetRequiredService(); + var protector = sp.GetRequiredService(); + + var principal = context.Principal!; + var sub = principal.FindFirstValue(ClaimTypes.NameIdentifier)!; + var email = principal.FindFirstValue(ClaimTypes.Email) ?? string.Empty; + var name = principal.FindFirstValue(ClaimTypes.Name); + var refreshToken = context.RefreshToken; + + var user = await db.Users.FirstOrDefaultAsync(u => u.GoogleSubjectId == sub); + if (user is null) + { + user = new User { GoogleSubjectId = sub, Email = email, DisplayName = name }; + db.Users.Add(user); + } + else + { + user.Email = email; + user.DisplayName = name; + } + + // Only overwrite the stored token when Google returns a new one. + if (!string.IsNullOrEmpty(refreshToken)) + user.EncryptedRefreshToken = protector.Protect(refreshToken); + + user.AccessTokenExpiresAtUtc = context.ExpiresIn is { } exp + ? DateTimeOffset.UtcNow.Add(exp) : null; + user.LastLoginUtc = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(); + + // Internal id used by ICurrentUser. + context.Identity!.AddClaim(new Claim("inboxintel:uid", user.Id.ToString())); + } +} diff --git a/src/InboxIntel.Api/Controllers/AiController.cs b/src/InboxIntel.Api/Controllers/AiController.cs new file mode 100644 index 0000000..c5f2b8d --- /dev/null +++ b/src/InboxIntel.Api/Controllers/AiController.cs @@ -0,0 +1,33 @@ +using InboxIntel.Application.Abstractions; +using Microsoft.AspNetCore.Mvc; + +namespace InboxIntel.Api.Controllers; + +/// +/// AI endpoints are read-only / advisory. They never trigger destructive +/// actions - suggestions are returned for the user to act on via /cleanup. +/// +public class AiController : ApiControllerBase +{ + private readonly IAiService _ai; + public AiController(IAiService ai) => _ai = ai; + + [HttpGet("status")] + public IActionResult Status() => Ok(new { enabled = _ai.IsEnabled }); + + [HttpPost("classify/{emailId:guid}")] + public async Task Classify(Guid emailId, CancellationToken ct) + => Ok(await _ai.ClassifyAsync(UserId, emailId, ct)); + + [HttpGet("summary")] + public async Task Summary(CancellationToken ct) + => Ok(await _ai.SummarizeInboxAsync(UserId, ct)); + + [HttpGet("suggestions")] + public async Task Suggestions(CancellationToken ct) + => Ok(await _ai.SuggestCleanupAsync(UserId, ct)); + + [HttpPost("generate-query")] + public async Task GenerateQuery([FromBody] string naturalLanguage, CancellationToken ct) + => Ok(await _ai.GenerateQueryAsync(UserId, naturalLanguage, ct)); +} diff --git a/src/InboxIntel.Api/Controllers/AnalyticsController.cs b/src/InboxIntel.Api/Controllers/AnalyticsController.cs new file mode 100644 index 0000000..4751e88 --- /dev/null +++ b/src/InboxIntel.Api/Controllers/AnalyticsController.cs @@ -0,0 +1,30 @@ +using InboxIntel.Application.Abstractions; +using Microsoft.AspNetCore.Mvc; + +namespace InboxIntel.Api.Controllers; + +public class AnalyticsController : ApiControllerBase +{ + private readonly IAnalyticsService _analytics; + public AnalyticsController(IAnalyticsService analytics) => _analytics = analytics; + + [HttpGet("dashboard")] + public async Task Dashboard(CancellationToken ct) => Ok(await _analytics.GetDashboardAsync(UserId, ct)); + + [HttpGet("health")] + public async Task Health(CancellationToken ct) => Ok(await _analytics.GetInboxHealthAsync(UserId, ct)); + + [HttpGet("top-senders")] + public async Task TopSenders([FromQuery] int take = 20, CancellationToken ct = default) + => Ok(await _analytics.GetTopSendersAsync(UserId, take, ct)); + + [HttpGet("volume")] + public async Task Volume([FromQuery] int days = 90, CancellationToken ct = default) + => Ok(await _analytics.GetVolumeOverTimeAsync(UserId, days, ct)); + + [HttpGet("heatmap")] + public async Task Heatmap(CancellationToken ct) => Ok(await _analytics.GetHeatmapAsync(UserId, ct)); + + [HttpGet("attachments")] + public async Task Attachments(CancellationToken ct) => Ok(await _analytics.GetAttachmentBreakdownAsync(UserId, ct)); +} diff --git a/src/InboxIntel.Api/Controllers/ApiControllerBase.cs b/src/InboxIntel.Api/Controllers/ApiControllerBase.cs new file mode 100644 index 0000000..b6fa0cc --- /dev/null +++ b/src/InboxIntel.Api/Controllers/ApiControllerBase.cs @@ -0,0 +1,17 @@ +using Asp.Versioning; +using InboxIntel.Application.Abstractions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace InboxIntel.Api.Controllers; + +[ApiController] +[Authorize] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/[controller]")] +public abstract class ApiControllerBase : ControllerBase +{ + private ICurrentUser? _currentUser; + protected ICurrentUser CurrentUser => _currentUser ??= HttpContext.RequestServices.GetRequiredService(); + protected Guid UserId => CurrentUser.UserId; +} diff --git a/src/InboxIntel.Api/Controllers/AuthController.cs b/src/InboxIntel.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..778f034 --- /dev/null +++ b/src/InboxIntel.Api/Controllers/AuthController.cs @@ -0,0 +1,39 @@ +using System.Security.Claims; +using Asp.Versioning; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.Google; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace InboxIntel.Api.Controllers; + +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/[controller]")] +public class AuthController : ControllerBase +{ + /// Begins the Google OAuth2 login flow. + [HttpGet("login")] + [AllowAnonymous] + public IActionResult Login([FromQuery] string? returnUrl = "/") + => Challenge(new AuthenticationProperties { RedirectUri = returnUrl }, GoogleDefaults.AuthenticationScheme); + + [HttpPost("logout")] + [Authorize] + public async Task Logout() + { + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return NoContent(); + } + + /// Returns the currently signed-in user, or 401. + [HttpGet("me")] + [Authorize] + public IActionResult Me() => Ok(new + { + UserId = User.FindFirstValue("inboxintel:uid"), + Email = User.FindFirstValue(ClaimTypes.Email), + Name = User.FindFirstValue(ClaimTypes.Name) + }); +} diff --git a/src/InboxIntel.Api/Controllers/CleanupController.cs b/src/InboxIntel.Api/Controllers/CleanupController.cs new file mode 100644 index 0000000..e59d154 --- /dev/null +++ b/src/InboxIntel.Api/Controllers/CleanupController.cs @@ -0,0 +1,24 @@ +using InboxIntel.Application.Abstractions; +using InboxIntel.Application.DTOs; +using Microsoft.AspNetCore.Mvc; + +namespace InboxIntel.Api.Controllers; + +public class CleanupController : ApiControllerBase +{ + private readonly ICleanupService _cleanup; + public CleanupController(ICleanupService cleanup) => _cleanup = cleanup; + + /// Preview which emails a cleanup action would affect. Always call before execute. + [HttpPost("preview")] + public async Task Preview([FromBody] CleanupRequestDto request, CancellationToken ct) + => Ok(await _cleanup.PreviewAsync(UserId, request, ct)); + + /// Execute a cleanup action. Destructive actions require Confirmed = true. + [HttpPost("execute")] + public async Task Execute([FromBody] CleanupRequestDto request, CancellationToken ct) + { + var result = await _cleanup.ExecuteAsync(UserId, request, ct); + return result.Succeeded ? Ok(result.Value) : BadRequest(new { error = result.Error }); + } +} diff --git a/src/InboxIntel.Api/Controllers/ExportController.cs b/src/InboxIntel.Api/Controllers/ExportController.cs new file mode 100644 index 0000000..31a5292 --- /dev/null +++ b/src/InboxIntel.Api/Controllers/ExportController.cs @@ -0,0 +1,24 @@ +using InboxIntel.Application.Abstractions; +using Microsoft.AspNetCore.Mvc; + +namespace InboxIntel.Api.Controllers; + +public class ExportController : ApiControllerBase +{ + private readonly IExportService _export; + public ExportController(IExportService export) => _export = export; + + /// Export an inbox report. format = pdf | csv | json. + [HttpGet("report")] + public async Task Report([FromQuery] string format = "pdf", CancellationToken ct = default) + { + var fmt = format.ToLowerInvariant() switch + { + "csv" => ExportFormat.Csv, + "json" => ExportFormat.Json, + _ => ExportFormat.Pdf + }; + var (content, contentType, fileName) = await _export.ExportReportAsync(UserId, fmt, ct); + return File(content, contentType, fileName); + } +} diff --git a/src/InboxIntel.Api/Controllers/SearchController.cs b/src/InboxIntel.Api/Controllers/SearchController.cs new file mode 100644 index 0000000..5b37d57 --- /dev/null +++ b/src/InboxIntel.Api/Controllers/SearchController.cs @@ -0,0 +1,25 @@ +using InboxIntel.Application.Abstractions; +using InboxIntel.Application.DTOs; +using InboxIntel.Application.Search; +using Microsoft.AspNetCore.Mvc; + +namespace InboxIntel.Api.Controllers; + +public class SearchController : ApiControllerBase +{ + private readonly ISearchService _search; + public SearchController(ISearchService search) => _search = search; + + /// Structured search via JSON body. + [HttpPost] + public async Task Search([FromBody] SearchRequestDto request, CancellationToken ct) + => Ok(await _search.SearchAsync(UserId, request, ct)); + + /// Gmail-like query string search, e.g. ?q=from:github.com is:unread. + [HttpGet] + public async Task Query([FromQuery] string? q, [FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken ct = default) + { + var parsed = GmailQueryParser.Parse(q, page, pageSize); + return Ok(await _search.SearchAsync(UserId, parsed, ct)); + } +} diff --git a/src/InboxIntel.Api/Controllers/SyncController.cs b/src/InboxIntel.Api/Controllers/SyncController.cs new file mode 100644 index 0000000..09c4a3e --- /dev/null +++ b/src/InboxIntel.Api/Controllers/SyncController.cs @@ -0,0 +1,29 @@ +using InboxIntel.Application.Abstractions; +using Microsoft.AspNetCore.Mvc; + +namespace InboxIntel.Api.Controllers; + +public class SyncController : ApiControllerBase +{ + private readonly ISyncService _sync; + public SyncController(ISyncService sync) => _sync = sync; + + [HttpGet("status")] + public async Task Status(CancellationToken ct) + => Ok(new { status = (await _sync.GetStatusAsync(UserId, ct)).ToString() }); + + /// Triggers a full inbox sync (runs in the background task queue in production). + [HttpPost("full")] + public async Task Full(CancellationToken ct) + { + await _sync.RunFullSyncAsync(UserId, ct); + return Accepted(); + } + + [HttpPost("incremental")] + public async Task Incremental(CancellationToken ct) + { + await _sync.RunIncrementalSyncAsync(UserId, ct); + return Accepted(); + } +} diff --git a/src/InboxIntel.Api/Controllers/UnsubscribeController.cs b/src/InboxIntel.Api/Controllers/UnsubscribeController.cs new file mode 100644 index 0000000..b19f077 --- /dev/null +++ b/src/InboxIntel.Api/Controllers/UnsubscribeController.cs @@ -0,0 +1,31 @@ +using InboxIntel.Application.Abstractions; +using InboxIntel.Application.DTOs; +using Microsoft.AspNetCore.Mvc; + +namespace InboxIntel.Api.Controllers; + +public class UnsubscribeController : ApiControllerBase +{ + private readonly IUnsubscribeService _unsub; + public UnsubscribeController(IUnsubscribeService unsub) => _unsub = unsub; + + [HttpPost("detect")] + public async Task Detect(CancellationToken ct) + { + await _unsub.DetectAsync(UserId, ct); + return NoContent(); + } + + /// Senders that are safe to unsubscribe from, ranked by volume. + [HttpGet("safe-list")] + public async Task SafeList(CancellationToken ct) + => Ok(await _unsub.GetSafeToUnsubscribeAsync(UserId, ct)); + + /// Process the confirmed unsubscribe queue. Requires Confirmed = true. + [HttpPost("process")] + public async Task Process([FromBody] UnsubscribeRequestDto request, CancellationToken ct) + { + var result = await _unsub.ProcessQueueAsync(UserId, request, ct); + return result.Succeeded ? Ok(result.Value) : BadRequest(new { error = result.Error }); + } +} diff --git a/src/InboxIntel.Api/Controllers/WidgetLayoutController.cs b/src/InboxIntel.Api/Controllers/WidgetLayoutController.cs new file mode 100644 index 0000000..0dc1dec --- /dev/null +++ b/src/InboxIntel.Api/Controllers/WidgetLayoutController.cs @@ -0,0 +1,53 @@ +using InboxIntel.Application.Abstractions; +using InboxIntel.Domain.Entities; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace InboxIntel.Api.Controllers; + +public record WidgetLayoutDto(string WidgetKey, int X, int Y, int W, int H, bool Visible, int SortOrder, string? SettingsJson); + +/// Persists the user's draggable/resizable dashboard layout. +public class WidgetLayoutController : ApiControllerBase +{ + private readonly IAppDbContext _db; + public WidgetLayoutController(IAppDbContext db) => _db = db; + + [HttpGet] + public async Task Get(CancellationToken ct) + { + var layouts = await _db.WidgetLayouts + .Where(w => w.UserId == UserId) + .OrderBy(w => w.SortOrder) + .Select(w => new WidgetLayoutDto(w.WidgetKey, w.X, w.Y, w.W, w.H, w.Visible, w.SortOrder, w.SettingsJson)) + .ToListAsync(ct); + return Ok(layouts); + } + + /// Upserts the full layout for the user (replace semantics). + [HttpPut] + public async Task Save([FromBody] List layout, CancellationToken ct) + { + var existing = await _db.WidgetLayouts.Where(w => w.UserId == UserId).ToListAsync(ct); + var byKey = existing.ToDictionary(w => w.WidgetKey); + + foreach (var dto in layout) + { + if (byKey.TryGetValue(dto.WidgetKey, out var w)) + { + w.X = dto.X; w.Y = dto.Y; w.W = dto.W; w.H = dto.H; + w.Visible = dto.Visible; w.SortOrder = dto.SortOrder; w.SettingsJson = dto.SettingsJson; + } + else + { + _db.WidgetLayouts.Add(new WidgetLayout + { + UserId = UserId, WidgetKey = dto.WidgetKey, X = dto.X, Y = dto.Y, W = dto.W, H = dto.H, + Visible = dto.Visible, SortOrder = dto.SortOrder, SettingsJson = dto.SettingsJson + }); + } + } + await _db.SaveChangesAsync(ct); + return NoContent(); + } +} diff --git a/src/InboxIntel.Api/Dockerfile b/src/InboxIntel.Api/Dockerfile new file mode 100644 index 0000000..65a7424 --- /dev/null +++ b/src/InboxIntel.Api/Dockerfile @@ -0,0 +1,20 @@ +# Multi-stage build for the ASP.NET Core API. +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +# Copy solution + project files first for layer-cached restore. +COPY Directory.Build.props ./ +COPY src/InboxIntel.Domain/InboxIntel.Domain.csproj src/InboxIntel.Domain/ +COPY src/InboxIntel.Application/InboxIntel.Application.csproj src/InboxIntel.Application/ +COPY src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj src/InboxIntel.Infrastructure/ +COPY src/InboxIntel.Api/InboxIntel.Api.csproj src/InboxIntel.Api/ +RUN dotnet restore src/InboxIntel.Api/InboxIntel.Api.csproj + +COPY src/ src/ +RUN dotnet publish src/InboxIntel.Api/InboxIntel.Api.csproj -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8080 +ENTRYPOINT ["dotnet", "InboxIntel.Api.dll"] diff --git a/src/InboxIntel.Api/InboxIntel.Api.csproj b/src/InboxIntel.Api/InboxIntel.Api.csproj new file mode 100644 index 0000000..5e91cd3 --- /dev/null +++ b/src/InboxIntel.Api/InboxIntel.Api.csproj @@ -0,0 +1,20 @@ + + + InboxIntel.Api + InboxIntel.Api + + + + + + + + + + + + + + + + diff --git a/src/InboxIntel.Api/Program.cs b/src/InboxIntel.Api/Program.cs new file mode 100644 index 0000000..413804c --- /dev/null +++ b/src/InboxIntel.Api/Program.cs @@ -0,0 +1,100 @@ +using System.Security.Claims; +using Asp.Versioning; +using InboxIntel.Api.Auth; +using InboxIntel.Application; +using InboxIntel.Application.Abstractions; +using InboxIntel.Infrastructure; +using InboxIntel.Infrastructure.Configuration; +using InboxIntel.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.Google; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Serilog; + +var builder = WebApplication.CreateBuilder(args); + +// Structured logging (Serilog). Note: token values are never logged. +builder.Host.UseSerilog((ctx, cfg) => cfg + .ReadFrom.Configuration(ctx.Configuration) + .Enrich.FromLogContext() + .WriteTo.Console()); + +// Persist Data Protection keys so encrypted refresh tokens survive restarts. +builder.Services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(builder.Configuration["DataProtection:KeyPath"] ?? "/keys")) + .SetApplicationName("InboxIntel"); + +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(builder.Configuration); + +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); + +// Authentication: cookie session established via Google OAuth2 (only login method). +var google = builder.Configuration.GetSection(GoogleOAuthOptions.SectionName).Get() ?? new(); +builder.Services.AddAuthentication(options => + { + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme; + }) + .AddCookie(options => + { + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.ExpireTimeSpan = TimeSpan.FromDays(7); + }) + .AddGoogle(options => + { + options.ClientId = google.ClientId; + options.ClientSecret = google.ClientSecret; + options.AccessType = "offline"; // request a refresh token + options.SaveTokens = true; + foreach (var scope in google.Scopes) options.Scope.Add(scope); + options.Events.OnCreatingTicket = GoogleAuthEvents.OnCreatingTicketAsync; + }); + +builder.Services.AddAuthorization(); + +builder.Services.AddApiVersioning(o => +{ + o.DefaultApiVersion = new ApiVersion(1, 0); + o.AssumeDefaultVersionWhenUnspecified = true; + o.ReportApiVersions = true; + o.ApiVersionReader = new UrlSegmentApiVersionReader(); +}).AddApiExplorer(o => { o.GroupNameFormat = "'v'VVV"; o.SubstituteApiVersionInUrl = true; }); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddCors(o => o.AddPolicy("frontend", p => p + .WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get() ?? new[] { "http://localhost:5173" }) + .AllowAnyHeader().AllowAnyMethod().AllowCredentials())); + +var app = builder.Build(); + +// Apply migrations on startup so `docker-compose up` yields a ready schema. +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + if (db.Database.IsRelational() && app.Configuration.GetValue("Database:AutoMigrate", true)) + await db.Database.MigrateAsync(); +} + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseSerilogRequestLogging(); +app.UseCors("frontend"); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); + +public partial class Program { } diff --git a/src/InboxIntel.Api/Properties/launchSettings.json b/src/InboxIntel.Api/Properties/launchSettings.json new file mode 100644 index 0000000..543069b --- /dev/null +++ b/src/InboxIntel.Api/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/InboxIntel.Api/appsettings.Development.json b/src/InboxIntel.Api/appsettings.Development.json new file mode 100644 index 0000000..5f4f713 --- /dev/null +++ b/src/InboxIntel.Api/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "DataProtection": { + "KeyPath": "./keys" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Debug" + } + } +} diff --git a/src/InboxIntel.Api/appsettings.json b/src/InboxIntel.Api/appsettings.json new file mode 100644 index 0000000..a42d44c --- /dev/null +++ b/src/InboxIntel.Api/appsettings.json @@ -0,0 +1,49 @@ +{ + "ConnectionStrings": { + "Postgres": "Host=localhost;Port=5432;Database=inboxintel;Username=inboxintel;Password=inboxintel" + }, + "Database": { + "AutoMigrate": true + }, + "DataProtection": { + "KeyPath": "/keys" + }, + "GoogleOAuth": { + "ClientId": "", + "ClientSecret": "", + "Scopes": [ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.modify" + ] + }, + "GmailSync": { + "PageSize": 100, + "MaxParallelism": 4, + "MaxRetries": 5, + "BackoffBaseMs": 500, + "DailySyncHourUtc": 3 + }, + "Ai": { + "Mode": "Disabled", + "OllamaBaseUrl": "http://localhost:11434", + "OllamaModel": "llama3.1", + "OpenAiApiKey": "", + "OpenAiModel": "gpt-4o-mini" + }, + "Cors": { + "Origins": [ "http://localhost:5173" ] + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } + }, + "AllowedHosts": "*" +} diff --git a/src/InboxIntel.Application/Abstractions/IAppDbContext.cs b/src/InboxIntel.Application/Abstractions/IAppDbContext.cs new file mode 100644 index 0000000..5260707 --- /dev/null +++ b/src/InboxIntel.Application/Abstractions/IAppDbContext.cs @@ -0,0 +1,26 @@ +using InboxIntel.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace InboxIntel.Application.Abstractions; + +/// +/// Abstraction over the EF Core DbContext so the Application layer can query +/// without depending on Infrastructure. Implemented by AppDbContext. +/// +public interface IAppDbContext +{ + DbSet Users { get; } + DbSet Emails { get; } + DbSet Threads { get; } + DbSet Senders { get; } + DbSet Domains { get; } + DbSet Attachments { get; } + DbSet