chore: init project

This commit is contained in:
cesnimda
2026-06-30 15:53:32 +02:00
commit f43ef5f945
94 changed files with 4405 additions and 0 deletions
+12
View File
@@ -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
+68
View File
@@ -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/
+11
View File
@@ -0,0 +1,11 @@
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>
+48
View File
@@ -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
+77
View File
@@ -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.
+62
View File
@@ -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:
+45
View File
@@ -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.
+2
View File
@@ -0,0 +1,2 @@
# Backend API target for the Vite dev proxy.
VITE_API_TARGET=http://localhost:5080
+12
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>InboxIntel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+20
View File
@@ -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;
}
}
+24
View File
@@ -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"
}
}
+57
View File
@@ -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;
+39
View File
@@ -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 (
<div className="app">
<header className="topbar">
<div className="brand">📥 InboxIntel</div>
<nav>
{nav.map((n) => (
<Link key={n.to} to={n.to} className={loc.pathname === n.to ? 'active' : ''}>
{n.label}
</Link>
))}
</nav>
<div className="spacer" />
<button onClick={() => SyncApi.incremental()}>Sync now</button>
<span className="user">{user?.email}</span>
</header>
<main>
<Outlet />
</main>
</div>
);
}
+113
View File
@@ -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 (
<div className="widget stat">
<div className="stat-value">{value}</div>
<div className="stat-label">{label}</div>
</div>
);
}
export function HealthWidget({ health }) {
if (!health) return <Empty />;
return (
<div className="widget">
<h3>Inbox Health</h3>
<div className={`health-score grade-${health.grade}`}>{health.score}<span>/100</span></div>
<div className="grade">Grade {health.grade}</div>
<ul className="recs">{health.recommendations.map((r, i) => <li key={i}>{r}</li>)}</ul>
</div>
);
}
export function TopSendersWidget({ senders }) {
if (!senders) return <Empty />;
return (
<div className="widget">
<h3>Top Senders</h3>
<table className="mini">
<tbody>
{senders.map((s) => (
<tr key={s.senderId}>
<td title={s.address}>{s.displayName || s.address}</td>
<td className="num">{s.emailCount}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function VolumeWidget({ volume }) {
if (!volume) return <Empty />;
const data = {
labels: volume.map((p) => p.day),
datasets: [{ label: 'Emails', data: volume.map((p) => p.count), borderColor: '#4f8cff', tension: 0.3 }]
};
return (
<div className="widget">
<h3>Email Volume</h3>
<Line data={data} options={{ plugins: { legend: { display: false } }, maintainAspectRatio: false }} />
</div>
);
}
export function HeatmapWidget({ heatmap }) {
if (!heatmap) return <Empty />;
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 (
<div className="widget">
<h3>Activity Heatmap</h3>
<div className="heatmap">
{days.map((d, dow) => (
<div className="hm-row" key={dow}>
<span className="hm-day">{d}</span>
{Array.from({ length: 24 }, (_, h) => {
const v = grid[`${dow}-${h}`] || 0;
return <span key={h} className="hm-cell" style={{ opacity: 0.1 + 0.9 * (v / max) }} title={`${d} ${h}:00 — ${v}`} />;
})}
</div>
))}
</div>
</div>
);
}
export function AttachmentsWidget({ attachments }) {
if (!attachments) return <Empty />;
const data = {
labels: attachments.map((a) => a.mimeBucket),
datasets: [{ data: attachments.map((a) => a.totalBytes), backgroundColor: ['#4f8cff', '#6fcf97', '#f2c94c', '#eb5757', '#bb6bd9', '#56ccf2', '#a0a0a0'] }]
};
return (
<div className="widget">
<h3>Attachments by Type</h3>
<Doughnut data={data} options={{ plugins: { legend: { position: 'right' } }, maintainAspectRatio: false }} />
<div className="muted">Total: {fmtBytes(attachments.reduce((s, a) => s + a.totalBytes, 0))}</div>
</div>
);
}
export function StorageWidget({ bytes }) {
return <StatCard label="Estimated Storage" value={fmtBytes(bytes)} />;
}
function Empty() { return <div className="widget"><div className="muted">No data yet run a sync.</div></div>; }
+22
View File
@@ -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(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/cleanup" element={<Cleanup />} />
<Route path="/unsubscribe" element={<Unsubscribe />} />
</Route>
</Routes>
</BrowserRouter>
</React.StrictMode>
);
+63
View File
@@ -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 (
<div className="page">
<h2>Bulk Cleanup</h2>
<p className="muted">Preview is required before any action runs. Destructive actions ask for confirmation.</p>
<div className="form-row">
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Gmail-like query, e.g. from:github.com is:read" />
<select value={action} onChange={(e) => setAction(Number(e.target.value))}>
{ACTIONS.map((a) => <option key={a.v} value={a.v}>{a.label}</option>)}
</select>
<button onClick={doPreview} disabled={busy}>Preview</button>
</div>
{preview && (
<div className="preview card">
<strong>{preview.affectedCount}</strong> emails match
({(preview.affectedSizeBytes / 1048576).toFixed(1)} MB).
<ul>{preview.sample.map((e) => <li key={e.id}>{e.subject} <span className="muted">{e.senderAddress}</span></li>)}</ul>
<button className={isDestructive(action) ? 'danger' : ''} onClick={doExecute} disabled={busy}>
Confirm &amp; {ACTIONS.find((a) => a.v === action)?.label}
</button>
</div>
)}
{result && <div className="card success">Done: {result.succeededCount} succeeded, {result.failedCount} failed.</div>}
</div>
);
}
+101
View File
@@ -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 <HealthWidget health={data?.health} />;
case 'total-emails': return <StatCard label="Total Emails" value={(data?.totalEmails ?? 0).toLocaleString()} />;
case 'unread-emails': return <StatCard label="Unread" value={(data?.unreadEmails ?? 0).toLocaleString()} />;
case 'storage': return <StorageWidget bytes={data?.storageEstimateBytes} />;
case 'top-senders': return <TopSendersWidget senders={data?.topSenders} />;
case 'volume': return <VolumeWidget volume={data?.volumeOverTime} />;
case 'heatmap': return <HeatmapWidget heatmap={data?.heatmap} />;
case 'attachments': return <AttachmentsWidget attachments={data?.attachmentBreakdown} />;
default: return null;
}
};
return (
<div className="dashboard">
<div className="toolbar">
<div className="widget-toggles">
{ALL_WIDGETS.map((k) => (
<label key={k}>
<input type="checkbox" checked={!hidden.includes(k)} onChange={() => toggle(k)} /> {k}
</label>
))}
</div>
<div className="spacer" />
<a className="btn" href={ExportApi.reportUrl('pdf')}>Export PDF</a>
<a className="btn" href={ExportApi.reportUrl('csv')}>CSV</a>
<a className="btn" href={ExportApi.reportUrl('json')}>JSON</a>
</div>
<GridLayout
className="layout"
layout={visibleLayout}
cols={12}
rowHeight={60}
width={1200}
onLayoutChange={onLayoutChange}
draggableHandle=".widget h3, .widget .stat-label"
>
{visibleLayout.map((l) => (
<div key={l.i} className="grid-item">{render(l.i)}</div>
))}
</GridLayout>
</div>
);
}
+51
View File
@@ -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 (
<div className="page">
<h2>Unsubscribe Manager</h2>
<div className="form-row">
<button onClick={detect} disabled={busy}>Re-scan for subscriptions</button>
<button onClick={process} disabled={busy} className="danger">Unsubscribe selected</button>
</div>
<table className="grid">
<thead><tr><th></th><th>Sender</th><th>Domain</th><th>Method</th><th>Emails</th><th>Status</th></tr></thead>
<tbody>
{items.map((it) => (
<tr key={it.id}>
<td><input type="checkbox" checked={!!selected[it.id]} onChange={(e) => setSelected({ ...selected, [it.id]: e.target.checked })} /></td>
<td>{it.senderAddress}</td>
<td>{it.domain}</td>
<td>{METHOD[it.method]}</td>
<td className="num">{it.emailCount}</td>
<td>{STATUS[it.status]}</td>
</tr>
))}
{!items.length && <tr><td colSpan="6" className="muted">Nothing detected yet. Run a scan.</td></tr>}
</tbody>
</table>
</div>
);
}
+63
View File
@@ -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); }
+16
View File
@@ -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
}
}
}
});
+21
View File
@@ -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;
}
}
+22
View File
@@ -0,0 +1,22 @@
using System.Security.Claims;
using InboxIntel.Application.Abstractions;
namespace InboxIntel.Api.Auth;
/// <summary>Resolves the authenticated user's id from the cookie principal.</summary>
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;
}
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
public static class GoogleAuthEvents
{
public static async Task OnCreatingTicketAsync(OAuthCreatingTicketContext context)
{
var sp = context.HttpContext.RequestServices;
var db = sp.GetRequiredService<AppDbContext>();
var protector = sp.GetRequiredService<ITokenProtector>();
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()));
}
}
@@ -0,0 +1,33 @@
using InboxIntel.Application.Abstractions;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
/// <summary>
/// AI endpoints are read-only / advisory. They never trigger destructive
/// actions - suggestions are returned for the user to act on via /cleanup.
/// </summary>
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<IActionResult> Classify(Guid emailId, CancellationToken ct)
=> Ok(await _ai.ClassifyAsync(UserId, emailId, ct));
[HttpGet("summary")]
public async Task<IActionResult> Summary(CancellationToken ct)
=> Ok(await _ai.SummarizeInboxAsync(UserId, ct));
[HttpGet("suggestions")]
public async Task<IActionResult> Suggestions(CancellationToken ct)
=> Ok(await _ai.SuggestCleanupAsync(UserId, ct));
[HttpPost("generate-query")]
public async Task<IActionResult> GenerateQuery([FromBody] string naturalLanguage, CancellationToken ct)
=> Ok(await _ai.GenerateQueryAsync(UserId, naturalLanguage, ct));
}
@@ -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<IActionResult> Dashboard(CancellationToken ct) => Ok(await _analytics.GetDashboardAsync(UserId, ct));
[HttpGet("health")]
public async Task<IActionResult> Health(CancellationToken ct) => Ok(await _analytics.GetInboxHealthAsync(UserId, ct));
[HttpGet("top-senders")]
public async Task<IActionResult> TopSenders([FromQuery] int take = 20, CancellationToken ct = default)
=> Ok(await _analytics.GetTopSendersAsync(UserId, take, ct));
[HttpGet("volume")]
public async Task<IActionResult> Volume([FromQuery] int days = 90, CancellationToken ct = default)
=> Ok(await _analytics.GetVolumeOverTimeAsync(UserId, days, ct));
[HttpGet("heatmap")]
public async Task<IActionResult> Heatmap(CancellationToken ct) => Ok(await _analytics.GetHeatmapAsync(UserId, ct));
[HttpGet("attachments")]
public async Task<IActionResult> Attachments(CancellationToken ct) => Ok(await _analytics.GetAttachmentBreakdownAsync(UserId, ct));
}
@@ -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<ICurrentUser>();
protected Guid UserId => CurrentUser.UserId;
}
@@ -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
{
/// <summary>Begins the Google OAuth2 login flow.</summary>
[HttpGet("login")]
[AllowAnonymous]
public IActionResult Login([FromQuery] string? returnUrl = "/")
=> Challenge(new AuthenticationProperties { RedirectUri = returnUrl }, GoogleDefaults.AuthenticationScheme);
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return NoContent();
}
/// <summary>Returns the currently signed-in user, or 401.</summary>
[HttpGet("me")]
[Authorize]
public IActionResult Me() => Ok(new
{
UserId = User.FindFirstValue("inboxintel:uid"),
Email = User.FindFirstValue(ClaimTypes.Email),
Name = User.FindFirstValue(ClaimTypes.Name)
});
}
@@ -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;
/// <summary>Preview which emails a cleanup action would affect. Always call before execute.</summary>
[HttpPost("preview")]
public async Task<IActionResult> Preview([FromBody] CleanupRequestDto request, CancellationToken ct)
=> Ok(await _cleanup.PreviewAsync(UserId, request, ct));
/// <summary>Execute a cleanup action. Destructive actions require Confirmed = true.</summary>
[HttpPost("execute")]
public async Task<IActionResult> 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 });
}
}
@@ -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;
/// <summary>Export an inbox report. format = pdf | csv | json.</summary>
[HttpGet("report")]
public async Task<IActionResult> 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);
}
}
@@ -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;
/// <summary>Structured search via JSON body.</summary>
[HttpPost]
public async Task<IActionResult> Search([FromBody] SearchRequestDto request, CancellationToken ct)
=> Ok(await _search.SearchAsync(UserId, request, ct));
/// <summary>Gmail-like query string search, e.g. ?q=from:github.com is:unread.</summary>
[HttpGet]
public async Task<IActionResult> 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));
}
}
@@ -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<IActionResult> Status(CancellationToken ct)
=> Ok(new { status = (await _sync.GetStatusAsync(UserId, ct)).ToString() });
/// <summary>Triggers a full inbox sync (runs in the background task queue in production).</summary>
[HttpPost("full")]
public async Task<IActionResult> Full(CancellationToken ct)
{
await _sync.RunFullSyncAsync(UserId, ct);
return Accepted();
}
[HttpPost("incremental")]
public async Task<IActionResult> Incremental(CancellationToken ct)
{
await _sync.RunIncrementalSyncAsync(UserId, ct);
return Accepted();
}
}
@@ -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<IActionResult> Detect(CancellationToken ct)
{
await _unsub.DetectAsync(UserId, ct);
return NoContent();
}
/// <summary>Senders that are safe to unsubscribe from, ranked by volume.</summary>
[HttpGet("safe-list")]
public async Task<IActionResult> SafeList(CancellationToken ct)
=> Ok(await _unsub.GetSafeToUnsubscribeAsync(UserId, ct));
/// <summary>Process the confirmed unsubscribe queue. Requires Confirmed = true.</summary>
[HttpPost("process")]
public async Task<IActionResult> 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 });
}
}
@@ -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);
/// <summary>Persists the user's draggable/resizable dashboard layout.</summary>
public class WidgetLayoutController : ApiControllerBase
{
private readonly IAppDbContext _db;
public WidgetLayoutController(IAppDbContext db) => _db = db;
[HttpGet]
public async Task<IActionResult> 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);
}
/// <summary>Upserts the full layout for the user (replace semantics).</summary>
[HttpPut]
public async Task<IActionResult> Save([FromBody] List<WidgetLayoutDto> 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();
}
}
+20
View File
@@ -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"]
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>InboxIntel.Api</RootNamespace>
<AssemblyName>InboxIntel.Api</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\InboxIntel.Application\InboxIntel.Application.csproj" />
<ProjectReference Include="..\InboxIntel.Infrastructure\InboxIntel.Infrastructure.csproj" />
</ItemGroup>
</Project>
+100
View File
@@ -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<ICurrentUser, CurrentUser>();
// Authentication: cookie session established via Google OAuth2 (only login method).
var google = builder.Configuration.GetSection(GoogleOAuthOptions.SectionName).Get<GoogleOAuthOptions>() ?? 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<string[]>() ?? 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<AppDbContext>();
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 { }
@@ -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"
}
}
}
}
@@ -0,0 +1,10 @@
{
"DataProtection": {
"KeyPath": "./keys"
},
"Serilog": {
"MinimumLevel": {
"Default": "Debug"
}
}
}
+49
View File
@@ -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": "*"
}
@@ -0,0 +1,26 @@
using InboxIntel.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace InboxIntel.Application.Abstractions;
/// <summary>
/// Abstraction over the EF Core DbContext so the Application layer can query
/// without depending on Infrastructure. Implemented by AppDbContext.
/// </summary>
public interface IAppDbContext
{
DbSet<User> Users { get; }
DbSet<Email> Emails { get; }
DbSet<MailThread> Threads { get; }
DbSet<Sender> Senders { get; }
DbSet<MailDomain> Domains { get; }
DbSet<Attachment> Attachments { get; }
DbSet<Label> Labels { get; }
DbSet<EmailLabel> EmailLabels { get; }
DbSet<SyncState> SyncStates { get; }
DbSet<AnalyticsAggregate> AnalyticsAggregates { get; }
DbSet<WidgetLayout> WidgetLayouts { get; }
DbSet<UnsubscribeItem> UnsubscribeItems { get; }
Task<int> SaveChangesAsync(CancellationToken ct = default);
}
@@ -0,0 +1,18 @@
namespace InboxIntel.Application.Abstractions;
/// <summary>Resolves the authenticated user for the current request/scope.</summary>
public interface ICurrentUser
{
Guid UserId { get; }
bool IsAuthenticated { get; }
}
/// <summary>
/// Protects secrets (OAuth refresh tokens) at rest. Implemented with the
/// ASP.NET Core Data Protection API (AES). Tokens are never logged.
/// </summary>
public interface ITokenProtector
{
byte[] Protect(string plaintext);
string Unprotect(byte[] ciphertext);
}
@@ -0,0 +1,51 @@
using InboxIntel.Domain.Entities;
namespace InboxIntel.Application.Abstractions;
/// <summary>
/// Thin wrapper over the Gmail REST API. Implementations must respect quotas,
/// apply retry with exponential backoff, and validate every response.
/// </summary>
public interface IGmailService
{
Task<string> GetProfileHistoryIdAsync(Guid userId, CancellationToken ct = default);
/// <summary>Lists message ids, page by page, for a full sync.</summary>
Task<GmailMessagePage> ListMessageIdsAsync(Guid userId, string? pageToken, CancellationToken ct = default);
/// <summary>Fetches and parses a single message into a domain Email graph.</summary>
Task<GmailMessageDetail> GetMessageAsync(Guid userId, string gmailMessageId, CancellationToken ct = default);
/// <summary>Delta changes since a historyId, for incremental sync.</summary>
Task<GmailHistoryPage> ListHistoryAsync(Guid userId, string startHistoryId, string? pageToken, CancellationToken ct = default);
Task<IReadOnlyList<Label>> ListLabelsAsync(Guid userId, CancellationToken ct = default);
// Mutations used by the cleanup system.
Task BatchModifyAsync(Guid userId, IEnumerable<string> messageIds, IEnumerable<string> addLabelIds, IEnumerable<string> removeLabelIds, CancellationToken ct = default);
Task BatchTrashAsync(Guid userId, IEnumerable<string> messageIds, CancellationToken ct = default);
Task BatchDeleteAsync(Guid userId, IEnumerable<string> messageIds, CancellationToken ct = default);
}
public record GmailMessagePage(IReadOnlyList<string> MessageIds, string? NextPageToken, int ResultSizeEstimate);
public record GmailHistoryPage(IReadOnlyList<string> ChangedMessageIds, IReadOnlyList<string> DeletedMessageIds, string? NextPageToken, string? NewHistoryId);
/// <summary>Parsed message plus the extracted unsubscribe signals.</summary>
public record GmailMessageDetail(
string GmailMessageId,
string GmailThreadId,
string FromAddress,
string? FromDisplayName,
string? Subject,
string? Snippet,
string? BodyText,
DateTimeOffset SentAtUtc,
long SizeEstimateBytes,
bool IsUnread,
bool HasAttachments,
IReadOnlyList<string> LabelIds,
IReadOnlyList<(string FileName, string? MimeType, long Size, string? AttachmentId)> Attachments,
bool HasListUnsubscribe,
string? ListUnsubscribeRaw,
bool SupportsOneClickUnsubscribe);
@@ -0,0 +1,69 @@
using InboxIntel.Application.Common;
using InboxIntel.Application.DTOs;
using InboxIntel.Domain.Enums;
namespace InboxIntel.Application.Abstractions;
/// <summary>Orchestrates full + incremental Gmail sync, with resume support.</summary>
public interface ISyncService
{
Task RunFullSyncAsync(Guid userId, CancellationToken ct = default);
Task RunIncrementalSyncAsync(Guid userId, CancellationToken ct = default);
Task<SyncStatus> GetStatusAsync(Guid userId, CancellationToken ct = default);
}
public interface IAnalyticsService
{
Task<DashboardSummaryDto> GetDashboardAsync(Guid userId, CancellationToken ct = default);
Task<InboxHealthDto> GetInboxHealthAsync(Guid userId, CancellationToken ct = default);
Task<IReadOnlyList<SenderStatDto>> GetTopSendersAsync(Guid userId, int take = 20, CancellationToken ct = default);
Task<IReadOnlyList<TimeSeriesPointDto>> GetVolumeOverTimeAsync(Guid userId, int days = 90, CancellationToken ct = default);
Task<IReadOnlyList<HeatmapCellDto>> GetHeatmapAsync(Guid userId, CancellationToken ct = default);
Task<IReadOnlyList<AttachmentBreakdownDto>> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default);
Task RefreshAggregatesAsync(Guid userId, CancellationToken ct = default);
}
public interface ISearchService
{
Task<PagedResult<EmailSummaryDto>> SearchAsync(Guid userId, SearchRequestDto request, CancellationToken ct = default);
}
public interface ICleanupService
{
Task<CleanupPreviewDto> PreviewAsync(Guid userId, CleanupRequestDto request, CancellationToken ct = default);
Task<Result<CleanupResultDto>> ExecuteAsync(Guid userId, CleanupRequestDto request, CancellationToken ct = default);
}
public interface IUnsubscribeService
{
Task<IReadOnlyList<UnsubscribeItemDto>> GetSafeToUnsubscribeAsync(Guid userId, CancellationToken ct = default);
Task DetectAsync(Guid userId, CancellationToken ct = default);
Task<Result<CleanupResultDto>> ProcessQueueAsync(Guid userId, UnsubscribeRequestDto request, CancellationToken ct = default);
}
/// <summary>
/// AI layer. May be disabled, local (Ollama) or cloud (OpenAI). Per the
/// safety rules, AI NEVER executes destructive actions - it only suggests.
/// </summary>
public interface IAiService
{
bool IsEnabled { get; }
Task<AiClassificationDto> ClassifyAsync(Guid userId, Guid emailId, CancellationToken ct = default);
Task<InboxSummaryDto> SummarizeInboxAsync(Guid userId, CancellationToken ct = default);
Task<IReadOnlyList<AiCleanupSuggestionDto>> SuggestCleanupAsync(Guid userId, CancellationToken ct = default);
Task<GeneratedQueryDto> GenerateQueryAsync(Guid userId, string naturalLanguage, CancellationToken ct = default);
}
/// <summary>Low-level chat completion abstraction implemented by Ollama / OpenAI providers.</summary>
public interface IAiProvider
{
AiProviderMode Mode { get; }
Task<string> CompleteAsync(string systemPrompt, string userPrompt, CancellationToken ct = default);
}
public enum ExportFormat { Pdf, Csv, Json }
public interface IExportService
{
Task<(byte[] Content, string ContentType, string FileName)> ExportReportAsync(Guid userId, ExportFormat format, CancellationToken ct = default);
}
@@ -0,0 +1,29 @@
namespace InboxIntel.Application.Common;
/// <summary>Lightweight result wrapper to avoid throwing for expected failures.</summary>
public class Result
{
public bool Succeeded { get; init; }
public string? Error { get; init; }
public static Result Success() => new() { Succeeded = true };
public static Result Failure(string error) => new() { Succeeded = false, Error = error };
}
public class Result<T> : Result
{
public T? Value { get; init; }
public static Result<T> Success(T value) => new() { Succeeded = true, Value = value };
public static new Result<T> Failure(string error) => new() { Succeeded = false, Error = error };
}
/// <summary>Standard paged response used by list endpoints.</summary>
public class PagedResult<T>
{
public IReadOnlyList<T> Items { get; init; } = Array.Empty<T>();
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalCount { get; init; }
public int TotalPages => PageSize == 0 ? 0 : (int)Math.Ceiling(TotalCount / (double)PageSize);
}
+17
View File
@@ -0,0 +1,17 @@
using InboxIntel.Domain.Enums;
namespace InboxIntel.Application.DTOs;
public record AiClassificationDto(Guid EmailId, EmailCategory Category, double Confidence);
public record InboxSummaryDto(string Summary, IReadOnlyList<string> Highlights);
public record AiCleanupSuggestionDto(
string Title,
string Rationale,
CleanupActionType SuggestedAction,
string? Query,
int EstimatedAffected);
/// <summary>Natural language -> Gmail-like query suggestion.</summary>
public record GeneratedQueryDto(string NaturalLanguage, string GmailQuery);
@@ -0,0 +1,27 @@
namespace InboxIntel.Application.DTOs;
public record InboxHealthDto(
int Score, // 0-100
string Grade, // A-F
int TotalEmails,
int UnreadEmails,
int NewsletterCount,
int SafeToUnsubscribeCount,
long EstimatedStorageBytes,
IReadOnlyList<string> Recommendations);
public record TimeSeriesPointDto(DateOnly Day, int Count);
public record HeatmapCellDto(int DayOfWeek, int Hour, int Count);
public record AttachmentBreakdownDto(string MimeBucket, long TotalBytes, int Count);
public record DashboardSummaryDto(
InboxHealthDto Health,
int TotalEmails,
int UnreadEmails,
IReadOnlyList<SenderStatDto> TopSenders,
IReadOnlyList<TimeSeriesPointDto> VolumeOverTime,
IReadOnlyList<HeatmapCellDto> Heatmap,
IReadOnlyList<AttachmentBreakdownDto> AttachmentBreakdown,
long StorageEstimateBytes);
@@ -0,0 +1,38 @@
using InboxIntel.Domain.Enums;
namespace InboxIntel.Application.DTOs;
/// <summary>Request to run a bulk cleanup action over a set of emails or a query.</summary>
public record CleanupRequestDto(
CleanupActionType Action,
IReadOnlyList<Guid>? EmailIds,
string? Query,
string? LabelId,
bool Confirmed);
/// <summary>
/// Preview of what a cleanup action would affect. Per the safety rules, no
/// destructive action runs until the user confirms this preview.
/// </summary>
public record CleanupPreviewDto(
CleanupActionType Action,
int AffectedCount,
long AffectedSizeBytes,
IReadOnlyList<EmailSummaryDto> Sample);
public record CleanupResultDto(
CleanupActionType Action,
int SucceededCount,
int FailedCount,
IReadOnlyList<string> Errors);
public record UnsubscribeItemDto(
Guid Id,
string SenderAddress,
string Domain,
UnsubscribeMethod Method,
UnsubscribeStatus Status,
int EmailCount,
string? UnsubscribeTarget);
public record UnsubscribeRequestDto(IReadOnlyList<Guid> ItemIds, bool Confirmed);
@@ -0,0 +1,33 @@
using InboxIntel.Domain.Enums;
namespace InboxIntel.Application.DTOs;
public record EmailSummaryDto(
Guid Id,
string GmailMessageId,
string? Subject,
string? Snippet,
string SenderAddress,
string? SenderDisplayName,
DateTimeOffset SentAtUtc,
bool IsUnread,
bool HasAttachments,
long SizeEstimateBytes,
EmailCategory Category);
public record SenderStatDto(
Guid SenderId,
string Address,
string? DisplayName,
string Domain,
int EmailCount,
int UnreadCount,
long TotalSizeBytes,
bool HasUnsubscribe,
DateTimeOffset? LastReceivedUtc);
public record AttachmentDto(
Guid Id,
string FileName,
string? MimeType,
long SizeBytes);
@@ -0,0 +1,19 @@
namespace InboxIntel.Application.DTOs;
/// <summary>
/// Advanced search request. <see cref="Query"/> accepts Gmail-like syntax
/// (e.g. "from:github.com is:unread has:attachment newsletter") which the
/// query parser decomposes into structured filters; remaining free text runs
/// through PostgreSQL full-text search.
/// </summary>
public record SearchRequestDto(
string? Query,
string? Sender,
string? Domain,
DateOnly? From,
DateOnly? To,
bool? IsUnread,
bool? HasAttachments,
bool FuzzyMatch = false,
int Page = 1,
int PageSize = 50);
@@ -0,0 +1,14 @@
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
namespace InboxIntel.Application;
public static class DependencyInjection
{
/// <summary>Registers Application-layer services (validators, etc.).</summary>
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly);
return services;
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>InboxIntel.Application</RootNamespace>
<AssemblyName>InboxIntel.Application</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.9.2" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<!-- DbSet<> is exposed on IAppDbContext so the Application layer can query.
Pinned to 8.0.4 to match the Npgsql provider's Relational dependency. -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,51 @@
using System.Text.RegularExpressions;
using InboxIntel.Application.DTOs;
namespace InboxIntel.Application.Search;
/// <summary>
/// Parses Gmail-like query strings into a structured <see cref="SearchRequestDto"/>.
/// Supports operators: from:, to:, domain:, after:, before:, is:unread,
/// is:read, has:attachment. Any unmatched text becomes the free-text query.
/// </summary>
public static class GmailQueryParser
{
private static readonly Regex TokenRegex = new(
@"(?<key>from|to|domain|after|before|is|has):(?<val>""[^""]+""|\S+)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static SearchRequestDto Parse(string? raw, int page = 1, int pageSize = 50, bool fuzzy = false)
{
if (string.IsNullOrWhiteSpace(raw))
return new SearchRequestDto(null, null, null, null, null, null, null, fuzzy, page, pageSize);
string? sender = null, domain = null;
DateOnly? from = null, to = null;
bool? isUnread = null, hasAttachments = null;
var freeText = TokenRegex.Replace(raw, match =>
{
var key = match.Groups["key"].Value.ToLowerInvariant();
var val = match.Groups["val"].Value.Trim('"');
switch (key)
{
case "from": sender = val; break;
case "domain": domain = val; break;
case "after": if (DateOnly.TryParse(val, out var a)) from = a; break;
case "before": if (DateOnly.TryParse(val, out var b)) to = b; break;
case "is":
if (val.Equals("unread", StringComparison.OrdinalIgnoreCase)) isUnread = true;
else if (val.Equals("read", StringComparison.OrdinalIgnoreCase)) isUnread = false;
break;
case "has":
if (val.Equals("attachment", StringComparison.OrdinalIgnoreCase)) hasAttachments = true;
break;
}
return string.Empty;
}).Trim();
return new SearchRequestDto(
string.IsNullOrWhiteSpace(freeText) ? null : freeText,
sender, domain, from, to, isUnread, hasAttachments, fuzzy, page, pageSize);
}
}
@@ -0,0 +1,48 @@
using FluentValidation;
using InboxIntel.Application.DTOs;
using InboxIntel.Domain.Enums;
namespace InboxIntel.Application.Validation;
public class SearchRequestValidator : AbstractValidator<SearchRequestDto>
{
public SearchRequestValidator()
{
RuleFor(x => x.Page).GreaterThan(0);
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
RuleFor(x => x)
.Must(x => x.From is null || x.To is null || x.From <= x.To)
.WithMessage("'From' date must be on or before 'To' date.");
}
}
public class CleanupRequestValidator : AbstractValidator<CleanupRequestDto>
{
public CleanupRequestValidator()
{
RuleFor(x => x)
.Must(x => (x.EmailIds is { Count: > 0 }) || !string.IsNullOrWhiteSpace(x.Query))
.WithMessage("Provide either EmailIds or a Query to target emails.");
// Safety rule: destructive actions must be explicitly confirmed.
RuleFor(x => x.Confirmed)
.Equal(true)
.When(x => x.Action is CleanupActionType.Trash or CleanupActionType.HardDelete)
.WithMessage("Destructive actions require explicit confirmation.");
RuleFor(x => x.LabelId)
.NotEmpty()
.When(x => x.Action is CleanupActionType.AddLabel or CleanupActionType.RemoveLabel)
.WithMessage("A LabelId is required for label actions.");
}
}
public class UnsubscribeRequestValidator : AbstractValidator<UnsubscribeRequestDto>
{
public UnsubscribeRequestValidator()
{
RuleFor(x => x.ItemIds).NotEmpty();
RuleFor(x => x.Confirmed).Equal(true)
.WithMessage("Unsubscribe actions require explicit confirmation.");
}
}
@@ -0,0 +1,10 @@
namespace InboxIntel.Domain.Common;
/// <summary>
/// Base type for entities that track creation / modification timestamps.
/// </summary>
public abstract class AuditableEntity
{
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? UpdatedAtUtc { get; set; }
}
@@ -0,0 +1,25 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// Pre-computed daily rollup so dashboard widgets render instantly without
/// scanning the full email table. Refreshed by the analytics worker.
/// </summary>
public class AnalyticsAggregate : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
/// <summary>Calendar day (UTC, date-only) this row aggregates.</summary>
public DateOnly Day { get; set; }
public int TotalReceived { get; set; }
public int TotalUnread { get; set; }
public int NewsletterCount { get; set; }
public int WithAttachments { get; set; }
public long TotalSizeBytes { get; set; }
/// <summary>JSON: hour-of-day -> count, backing the heatmap widget.</summary>
public string? HourHistogramJson { get; set; }
}
@@ -0,0 +1,23 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// Attachment metadata only - the binary content is never downloaded or stored.
/// Powers the "attachment size breakdown" widget and storage estimates.
/// </summary>
public class Attachment : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public Guid EmailId { get; set; }
public Email? Email { get; set; }
/// <summary>Gmail attachment id (for on-demand download if ever needed).</summary>
public string? GmailAttachmentId { get; set; }
public string FileName { get; set; } = string.Empty;
public string? MimeType { get; set; }
public long SizeBytes { get; set; }
}
+59
View File
@@ -0,0 +1,59 @@
using InboxIntel.Domain.Common;
using InboxIntel.Domain.Enums;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// A single Gmail message stored locally. Designed for 100k+ rows per user:
/// search fields are indexed (see EmailConfiguration) and a generated
/// tsvector column backs PostgreSQL full-text search.
/// </summary>
public class Email : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
/// <summary>Gmail message id (stable, unique per account).</summary>
public string GmailMessageId { get; set; } = string.Empty;
public Guid ThreadId { get; set; }
public MailThread? Thread { get; set; }
public Guid SenderId { get; set; }
public Sender? Sender { get; set; }
public string? Subject { get; set; }
public string? Snippet { get; set; }
/// <summary>Plain-text body. Indexed for FTS via SearchVector.</summary>
public string? BodyText { get; set; }
public DateTimeOffset SentAtUtc { get; set; }
public DateTimeOffset? ReceivedAtUtc { get; set; }
public long SizeEstimateBytes { get; set; }
public bool IsUnread { get; set; }
public bool IsStarred { get; set; }
public bool IsImportant { get; set; }
public bool IsInInbox { get; set; }
public bool IsTrashed { get; set; }
public bool HasAttachments { get; set; }
// Unsubscribe signals captured at parse time.
public bool HasListUnsubscribe { get; set; }
public string? ListUnsubscribeRaw { get; set; }
public bool SupportsOneClickUnsubscribe { get; set; }
/// <summary>Heuristic/AI classification. Defaults to Unknown until classified.</summary>
public EmailCategory Category { get; set; } = EmailCategory.Unknown;
/// <summary>
/// PostgreSQL tsvector, maintained as a generated column. Never set in code;
/// mapped read-only for querying. Nullable so non-Postgres test providers work.
/// </summary>
public NpgsqlTypes.NpgsqlTsVector? SearchVector { get; set; }
public ICollection<EmailLabel> EmailLabels { get; set; } = new List<EmailLabel>();
public ICollection<Attachment> Attachments { get; set; } = new List<Attachment>();
}
+32
View File
@@ -0,0 +1,32 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>A Gmail label (system or user-defined), mapped locally.</summary>
public class Label : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
/// <summary>Gmail label id, e.g. "INBOX", "Label_42".</summary>
public string GmailLabelId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
/// <summary>"system" or "user".</summary>
public string Type { get; set; } = "user";
public string? ColorHex { get; set; }
public ICollection<EmailLabel> EmailLabels { get; set; } = new List<EmailLabel>();
}
/// <summary>Join entity for the many-to-many Email &lt;-&gt; Label relationship.</summary>
public class EmailLabel
{
public Guid EmailId { get; set; }
public Email? Email { get; set; }
public Guid LabelId { get; set; }
public Label? Label { get; set; }
}
@@ -0,0 +1,22 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// A sending domain (e.g. "github.com"), extracted from sender addresses.
/// Aggregating at the domain level powers fast grouping and the
/// safe-to-unsubscribe list.
/// </summary>
public class MailDomain : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
/// <summary>Lower-cased registrable domain, e.g. "news.github.com".</summary>
public string Name { get; set; } = string.Empty;
public int EmailCount { get; set; }
public bool IsBulkSender { get; set; }
public ICollection<Sender> Senders { get; set; } = new List<Sender>();
}
@@ -0,0 +1,23 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// A Gmail conversation thread, reconstructed from the Gmail threadId.
/// </summary>
public class MailThread : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
/// <summary>Gmail-assigned thread id.</summary>
public string GmailThreadId { get; set; } = string.Empty;
public string? Subject { get; set; }
public string? Snippet { get; set; }
public int MessageCount { get; set; }
public DateTimeOffset? FirstMessageUtc { get; set; }
public DateTimeOffset? LastMessageUtc { get; set; }
public ICollection<Email> Emails { get; set; } = new List<Email>();
}
+30
View File
@@ -0,0 +1,30 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// A distinct sender address (e.g. "noreply@github.com"). Cleanup and
/// unsubscribe operations are most often grouped by sender.
/// </summary>
public class Sender : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public Guid DomainId { get; set; }
public MailDomain? Domain { get; set; }
/// <summary>Lower-cased full address.</summary>
public string Address { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public int EmailCount { get; set; }
public int UnreadCount { get; set; }
public long TotalSizeBytes { get; set; }
public DateTimeOffset? LastReceivedUtc { get; set; }
/// <summary>True if any message from this sender carried a List-Unsubscribe header.</summary>
public bool HasUnsubscribe { get; set; }
public ICollection<Email> Emails { get; set; } = new List<Email>();
}
@@ -0,0 +1,33 @@
using InboxIntel.Domain.Common;
using InboxIntel.Domain.Enums;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// Tracks Gmail sync progress so a run can resume after failure and so
/// incremental (delta) syncs know where to continue from.
/// </summary>
public class SyncState : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public SyncStatus Status { get; set; } = SyncStatus.Idle;
public SyncType LastSyncType { get; set; } = SyncType.Full;
/// <summary>Gmail historyId watermark for incremental delta sync.</summary>
public string? LastHistoryId { get; set; }
/// <summary>Opaque pageToken to resume an interrupted full sync.</summary>
public string? ResumePageToken { get; set; }
public int TotalMessagesEstimate { get; set; }
public int MessagesProcessed { get; set; }
public DateTimeOffset? StartedUtc { get; set; }
public DateTimeOffset? CompletedUtc { get; set; }
public DateTimeOffset? LastSuccessfulSyncUtc { get; set; }
public int ConsecutiveFailures { get; set; }
public string? LastError { get; set; }
}
@@ -0,0 +1,29 @@
using InboxIntel.Domain.Common;
using InboxIntel.Domain.Enums;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// A detected unsubscribe opportunity, grouped per sender/domain. Items move
/// through a queue; actions only run after explicit user confirmation.
/// </summary>
public class UnsubscribeItem : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public Guid SenderId { get; set; }
public Sender? Sender { get; set; }
public UnsubscribeMethod Method { get; set; } = UnsubscribeMethod.None;
public UnsubscribeStatus Status { get; set; } = UnsubscribeStatus.Detected;
/// <summary>http(s) unsubscribe URL or mailto target extracted from header/body.</summary>
public string? UnsubscribeTarget { get; set; }
/// <summary>Number of emails from this sender (drives "safe to unsubscribe" ranking).</summary>
public int EmailCount { get; set; }
public DateTimeOffset? LastAttemptUtc { get; set; }
public string? ResultMessage { get; set; }
}
+31
View File
@@ -0,0 +1,31 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// A user authenticated via Google OAuth2. The system is single-user in the
/// initial version, but the schema is multi-user ready (every owned entity
/// carries a UserId foreign key).
/// </summary>
public class User : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>Google "sub" claim - stable unique identifier for the Google account.</summary>
public string GoogleSubjectId { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public string? PictureUrl { get; set; }
/// <summary>OAuth2 refresh token, AES-encrypted at rest. Never logged.</summary>
public byte[]? EncryptedRefreshToken { get; set; }
/// <summary>Most recent access token expiry, used to decide when to refresh.</summary>
public DateTimeOffset? AccessTokenExpiresAtUtc { get; set; }
public DateTimeOffset? LastLoginUtc { get; set; }
public ICollection<Email> Emails { get; set; } = new List<Email>();
public ICollection<WidgetLayout> WidgetLayouts { get; set; } = new List<WidgetLayout>();
}
@@ -0,0 +1,28 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// Persisted dashboard layout for a user. One row per widget instance,
/// storing grid geometry and visibility so the dashboard restores exactly.
/// </summary>
public class WidgetLayout : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
/// <summary>Stable widget key, e.g. "inbox-health", "top-senders".</summary>
public string WidgetKey { get; set; } = string.Empty;
// react-grid-layout geometry.
public int X { get; set; }
public int Y { get; set; }
public int W { get; set; } = 4;
public int H { get; set; } = 4;
public bool Visible { get; set; } = true;
public int SortOrder { get; set; }
/// <summary>Optional per-widget settings as JSON.</summary>
public string? SettingsJson { get; set; }
}
+67
View File
@@ -0,0 +1,67 @@
namespace InboxIntel.Domain.Enums;
/// <summary>AI-derived (or heuristic) classification of an email.</summary>
public enum EmailCategory
{
Unknown = 0,
Personal = 1,
Newsletter = 2,
Finance = 3,
Spam = 4,
Promotional = 5,
Social = 6,
Notification = 7
}
/// <summary>State machine for a sync run.</summary>
public enum SyncStatus
{
Idle = 0,
Running = 1,
Paused = 2,
Completed = 3,
Failed = 4
}
public enum SyncType
{
Full = 0,
Incremental = 1
}
/// <summary>Bulk cleanup action types. All are reversible except HardDelete.</summary>
public enum CleanupActionType
{
Archive = 0,
Trash = 1,
HardDelete = 2,
AddLabel = 3,
RemoveLabel = 4,
MarkRead = 5,
MarkUnread = 6
}
public enum UnsubscribeMethod
{
None = 0,
HttpLink = 1,
MailTo = 2,
OneClickPost = 3
}
public enum UnsubscribeStatus
{
Detected = 0,
Queued = 1,
InProgress = 2,
Succeeded = 3,
Failed = 4,
Skipped = 5
}
public enum AiProviderMode
{
Disabled = 0,
LocalOllama = 1,
CloudOpenAi = 2
}
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>InboxIntel.Domain</RootNamespace>
<AssemblyName>InboxIntel.Domain</AssemblyName>
</PropertyGroup>
<ItemGroup>
<!-- NpgsqlTypes.NpgsqlTsVector is used on the Email entity for FTS mapping. -->
<PackageReference Include="Npgsql" Version="8.0.3" />
</ItemGroup>
</Project>
@@ -0,0 +1,85 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Configuration;
using Microsoft.Extensions.Options;
using System.Net.Http.Json;
using System.Text.Json;
namespace InboxIntel.Infrastructure.Ai;
/// <summary>No-op provider used when AI is disabled. Returns empty completions.</summary>
public class NullAiProvider : IAiProvider
{
public AiProviderMode Mode => AiProviderMode.Disabled;
public Task<string> CompleteAsync(string systemPrompt, string userPrompt, CancellationToken ct = default)
=> Task.FromResult(string.Empty);
}
/// <summary>Local LLM via Ollama's /api/chat endpoint.</summary>
public class OllamaProvider : IAiProvider
{
private readonly HttpClient _http;
private readonly AiOptions _options;
public OllamaProvider(IHttpClientFactory factory, IOptions<AiOptions> options)
{
_options = options.Value;
_http = factory.CreateClient("ollama");
_http.BaseAddress = new Uri(_options.OllamaBaseUrl);
}
public AiProviderMode Mode => AiProviderMode.LocalOllama;
public async Task<string> CompleteAsync(string systemPrompt, string userPrompt, CancellationToken ct = default)
{
var payload = new
{
model = _options.OllamaModel,
stream = false,
messages = new[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userPrompt }
}
};
var resp = await _http.PostAsJsonAsync("/api/chat", payload, ct);
resp.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct));
return doc.RootElement.GetProperty("message").GetProperty("content").GetString() ?? string.Empty;
}
}
/// <summary>Cloud LLM via the OpenAI Chat Completions API (optional).</summary>
public class OpenAiProvider : IAiProvider
{
private readonly HttpClient _http;
private readonly AiOptions _options;
public OpenAiProvider(IHttpClientFactory factory, IOptions<AiOptions> options)
{
_options = options.Value;
_http = factory.CreateClient("openai");
_http.BaseAddress = new Uri("https://api.openai.com");
_http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _options.OpenAiApiKey);
}
public AiProviderMode Mode => AiProviderMode.CloudOpenAi;
public async Task<string> CompleteAsync(string systemPrompt, string userPrompt, CancellationToken ct = default)
{
var payload = new
{
model = _options.OpenAiModel,
messages = new[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userPrompt }
}
};
var resp = await _http.PostAsJsonAsync("/v1/chat/completions", payload, ct);
resp.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct));
return doc.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString() ?? string.Empty;
}
}
@@ -0,0 +1,85 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.DTOs;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace InboxIntel.Infrastructure.Ai;
/// <summary>
/// High-level AI features built on top of an <see cref="IAiProvider"/>.
/// SAFETY: this service only ever READS data and returns suggestions. It never
/// archives, deletes, labels, or unsubscribes - destructive actions always go
/// through the cleanup/unsubscribe services after explicit user confirmation.
/// </summary>
public class AiService : IAiService
{
private readonly IAiProvider _provider;
private readonly AppDbContext _db;
public AiService(IAiProvider provider, AppDbContext db)
{
_provider = provider;
_db = db;
}
public bool IsEnabled => _provider.Mode != AiProviderMode.Disabled;
public async Task<AiClassificationDto> ClassifyAsync(Guid userId, Guid emailId, CancellationToken ct = default)
{
var email = await _db.Emails.FirstOrDefaultAsync(e => e.UserId == userId && e.Id == emailId, ct)
?? throw new InvalidOperationException("Email not found.");
if (!IsEnabled) return new AiClassificationDto(emailId, email.Category, 0);
var prompt = $"Classify this email into one of: Personal, Newsletter, Finance, Spam, Promotional, Social, Notification.\n" +
$"Subject: {email.Subject}\nFrom: {email.SenderId}\nSnippet: {email.Snippet}\n" +
$"Reply with only the single category word.";
var raw = await _provider.CompleteAsync("You are an email classifier.", prompt, ct);
var category = Enum.TryParse<EmailCategory>(raw.Trim(), true, out var c) ? c : email.Category;
return new AiClassificationDto(emailId, category, raw.Length > 0 ? 0.8 : 0);
}
public async Task<InboxSummaryDto> SummarizeInboxAsync(Guid userId, CancellationToken ct = default)
{
if (!IsEnabled) return new InboxSummaryDto("AI is disabled.", Array.Empty<string>());
var recent = await _db.Emails.AsNoTracking()
.Where(e => e.UserId == userId && e.IsUnread)
.OrderByDescending(e => e.SentAtUtc).Take(50)
.Select(e => $"- {e.Subject} ({e.Sender!.Address})")
.ToListAsync(ct);
var summary = await _provider.CompleteAsync(
"You summarise an email inbox concisely.",
"Summarise these unread emails in 3-4 sentences and list up to 5 highlights:\n" + string.Join("\n", recent), ct);
return new InboxSummaryDto(summary, recent.Take(5).ToList());
}
public async Task<IReadOnlyList<AiCleanupSuggestionDto>> SuggestCleanupAsync(Guid userId, CancellationToken ct = default)
{
// Data-driven suggestions (work even without AI); AI can enrich the rationale.
var noisy = await _db.Senders.AsNoTracking()
.Where(s => s.UserId == userId && s.HasUnsubscribe)
.OrderByDescending(s => s.EmailCount).Take(5)
.Select(s => new { s.Address, s.EmailCount }).ToListAsync(ct);
return noisy.Select(s => new AiCleanupSuggestionDto(
$"Archive newsletters from {s.Address}",
$"{s.EmailCount} emails from this sender carry an unsubscribe header and are likely low-value.",
CleanupActionType.Archive,
$"from:{s.Address}",
s.EmailCount)).ToList();
}
public async Task<GeneratedQueryDto> GenerateQueryAsync(Guid userId, string naturalLanguage, CancellationToken ct = default)
{
if (!IsEnabled)
return new GeneratedQueryDto(naturalLanguage, naturalLanguage);
var gmailQuery = await _provider.CompleteAsync(
"Convert natural language into a Gmail search query using operators like from:, after:, before:, is:unread, has:attachment. Reply with only the query.",
naturalLanguage, ct);
return new GeneratedQueryDto(naturalLanguage, gmailQuery.Trim());
}
}
@@ -0,0 +1,140 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.DTOs;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace InboxIntel.Infrastructure.Analytics;
public class AnalyticsService : IAnalyticsService
{
private readonly AppDbContext _db;
public AnalyticsService(AppDbContext db) => _db = db;
public async Task<DashboardSummaryDto> GetDashboardAsync(Guid userId, CancellationToken ct = default)
{
var health = await GetInboxHealthAsync(userId, ct);
var top = await GetTopSendersAsync(userId, 10, ct);
var volume = await GetVolumeOverTimeAsync(userId, 90, ct);
var heatmap = await GetHeatmapAsync(userId, ct);
var attachments = await GetAttachmentBreakdownAsync(userId, ct);
return new DashboardSummaryDto(
health, health.TotalEmails, health.UnreadEmails, top, volume, heatmap, attachments,
health.EstimatedStorageBytes);
}
public async Task<InboxHealthDto> GetInboxHealthAsync(Guid userId, CancellationToken ct = default)
{
var emails = _db.Emails.Where(e => e.UserId == userId);
var total = await emails.CountAsync(ct);
var unread = await emails.CountAsync(e => e.IsUnread, ct);
var newsletters = await emails.CountAsync(e => e.Category == EmailCategory.Newsletter, ct);
var storage = total == 0 ? 0 : await emails.SumAsync(e => e.SizeEstimateBytes, ct);
var safeToUnsub = await _db.UnsubscribeItems.CountAsync(u => u.UserId == userId, ct);
// Health score: penalise high unread ratio and newsletter clutter.
var unreadRatio = total == 0 ? 0 : (double)unread / total;
var newsletterRatio = total == 0 ? 0 : (double)newsletters / total;
var score = (int)Math.Round(100 * (1 - 0.6 * unreadRatio - 0.4 * newsletterRatio));
score = Math.Clamp(score, 0, 100);
var grade = score switch { >= 90 => "A", >= 80 => "B", >= 70 => "C", >= 60 => "D", _ => "F" };
var recs = new List<string>();
if (unreadRatio > 0.3) recs.Add($"You have {unread:N0} unread emails. Consider bulk-marking older ones as read.");
if (newsletters > 50) recs.Add($"{newsletters:N0} newsletters detected. Review the safe-to-unsubscribe list.");
if (storage > 1_000_000_000) recs.Add("Inbox storage exceeds 1 GB. Clean up large attachments.");
if (recs.Count == 0) recs.Add("Your inbox is in good shape. Keep it up!");
return new InboxHealthDto(score, grade, total, unread, newsletters, safeToUnsub, storage, recs);
}
public async Task<IReadOnlyList<SenderStatDto>> GetTopSendersAsync(Guid userId, int take = 20, CancellationToken ct = default)
{
return await _db.Senders
.Where(s => s.UserId == userId)
.OrderByDescending(s => s.EmailCount)
.Take(take)
.Select(s => new SenderStatDto(
s.Id, s.Address, s.DisplayName, s.Domain!.Name, s.EmailCount, s.UnreadCount,
s.TotalSizeBytes, s.HasUnsubscribe, s.LastReceivedUtc))
.ToListAsync(ct);
}
public async Task<IReadOnlyList<TimeSeriesPointDto>> GetVolumeOverTimeAsync(Guid userId, int days = 90, CancellationToken ct = default)
{
var since = DateTimeOffset.UtcNow.AddDays(-days);
var raw = await _db.Emails
.Where(e => e.UserId == userId && e.SentAtUtc >= since)
.GroupBy(e => e.SentAtUtc.Date)
.Select(g => new { Day = g.Key, Count = g.Count() })
.ToListAsync(ct);
return raw.OrderBy(x => x.Day)
.Select(x => new TimeSeriesPointDto(DateOnly.FromDateTime(x.Day), x.Count))
.ToList();
}
public async Task<IReadOnlyList<HeatmapCellDto>> GetHeatmapAsync(Guid userId, CancellationToken ct = default)
{
var raw = await _db.Emails
.Where(e => e.UserId == userId)
.Select(e => new { e.SentAtUtc })
.ToListAsync(ct);
return raw
.GroupBy(x => new { Dow = (int)x.SentAtUtc.DayOfWeek, Hour = x.SentAtUtc.Hour })
.Select(g => new HeatmapCellDto(g.Key.Dow, g.Key.Hour, g.Count()))
.ToList();
}
public async Task<IReadOnlyList<AttachmentBreakdownDto>> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default)
{
var raw = await _db.Attachments
.Where(a => a.UserId == userId)
.Select(a => new { a.MimeType, a.SizeBytes })
.ToListAsync(ct);
return raw
.GroupBy(a => Bucket(a.MimeType))
.Select(g => new AttachmentBreakdownDto(g.Key, g.Sum(x => x.SizeBytes), g.Count()))
.OrderByDescending(x => x.TotalBytes)
.ToList();
}
private static string Bucket(string? mime) => mime switch
{
null => "other",
var m when m.StartsWith("image/") => "images",
var m when m.StartsWith("video/") => "video",
var m when m.StartsWith("audio/") => "audio",
var m when m.Contains("pdf") => "pdf",
var m when m.Contains("zip") || m.Contains("compressed") => "archives",
var m when m.Contains("spreadsheet") || m.Contains("excel") => "spreadsheets",
var m when m.Contains("word") || m.Contains("document") => "documents",
_ => "other"
};
public async Task RefreshAggregatesAsync(Guid userId, CancellationToken ct = default)
{
var since = DateTimeOffset.UtcNow.AddDays(-365);
var perDay = await _db.Emails
.Where(e => e.UserId == userId && e.SentAtUtc >= since)
.Select(e => new { e.SentAtUtc, e.IsUnread, e.Category, e.HasAttachments, e.SizeEstimateBytes })
.ToListAsync(ct);
var grouped = perDay.GroupBy(e => DateOnly.FromDateTime(e.SentAtUtc.UtcDateTime.Date));
foreach (var g in grouped)
{
var existing = await _db.AnalyticsAggregates.FirstOrDefaultAsync(a => a.UserId == userId && a.Day == g.Key, ct);
var agg = existing ?? new Domain.Entities.AnalyticsAggregate { UserId = userId, Day = g.Key };
agg.TotalReceived = g.Count();
agg.TotalUnread = g.Count(x => x.IsUnread);
agg.NewsletterCount = g.Count(x => x.Category == EmailCategory.Newsletter);
agg.WithAttachments = g.Count(x => x.HasAttachments);
agg.TotalSizeBytes = g.Sum(x => x.SizeEstimateBytes);
if (existing is null) _db.AnalyticsAggregates.Add(agg);
}
await _db.SaveChangesAsync(ct);
}
}
@@ -0,0 +1,118 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.Common;
using InboxIntel.Application.DTOs;
using InboxIntel.Application.Search;
using InboxIntel.Domain.Entities;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace InboxIntel.Infrastructure.Cleanup;
/// <summary>
/// Safe bulk cleanup. Every destructive call goes through Preview first and
/// requires an explicit Confirmed flag (enforced again here as defence in
/// depth, in addition to FluentValidation). Local state is updated to mirror
/// the Gmail mutation so the dashboard stays consistent.
/// </summary>
public class CleanupService : ICleanupService
{
private readonly AppDbContext _db;
private readonly IGmailService _gmail;
private readonly ISearchService _search;
private readonly ILogger<CleanupService> _logger;
public CleanupService(AppDbContext db, IGmailService gmail, ISearchService search, ILogger<CleanupService> logger)
{
_db = db;
_gmail = gmail;
_search = search;
_logger = logger;
}
public async Task<CleanupPreviewDto> PreviewAsync(Guid userId, CleanupRequestDto request, CancellationToken ct = default)
{
var emails = await ResolveTargetsAsync(userId, request, ct);
var sample = emails.Take(25).Select(e => new EmailSummaryDto(
e.Id, e.GmailMessageId, e.Subject, e.Snippet, e.Sender!.Address, e.Sender.DisplayName,
e.SentAtUtc, e.IsUnread, e.HasAttachments, e.SizeEstimateBytes, e.Category)).ToList();
return new CleanupPreviewDto(request.Action, emails.Count, emails.Sum(e => e.SizeEstimateBytes), sample);
}
public async Task<Result<CleanupResultDto>> ExecuteAsync(Guid userId, CleanupRequestDto request, CancellationToken ct = default)
{
// Safety: destructive actions must be confirmed.
if (request.Action is CleanupActionType.Trash or CleanupActionType.HardDelete && !request.Confirmed)
return Result<CleanupResultDto>.Failure("Destructive actions require explicit confirmation.");
var emails = await ResolveTargetsAsync(userId, request, ct);
if (emails.Count == 0)
return Result<CleanupResultDto>.Success(new CleanupResultDto(request.Action, 0, 0, Array.Empty<string>()));
var gmailIds = emails.Select(e => e.GmailMessageId).ToList();
var errors = new List<string>();
try
{
switch (request.Action)
{
case CleanupActionType.Archive:
await _gmail.BatchModifyAsync(userId, gmailIds, Array.Empty<string>(), new[] { "INBOX" }, ct);
emails.ForEach(e => e.IsInInbox = false);
break;
case CleanupActionType.Trash:
await _gmail.BatchTrashAsync(userId, gmailIds, ct);
emails.ForEach(e => e.IsTrashed = true);
break;
case CleanupActionType.HardDelete:
await _gmail.BatchDeleteAsync(userId, gmailIds, ct);
_db.Emails.RemoveRange(emails);
break;
case CleanupActionType.MarkRead:
await _gmail.BatchModifyAsync(userId, gmailIds, Array.Empty<string>(), new[] { "UNREAD" }, ct);
emails.ForEach(e => e.IsUnread = false);
break;
case CleanupActionType.MarkUnread:
await _gmail.BatchModifyAsync(userId, gmailIds, new[] { "UNREAD" }, Array.Empty<string>(), ct);
emails.ForEach(e => e.IsUnread = true);
break;
case CleanupActionType.AddLabel:
await _gmail.BatchModifyAsync(userId, gmailIds, new[] { request.LabelId! }, Array.Empty<string>(), ct);
break;
case CleanupActionType.RemoveLabel:
await _gmail.BatchModifyAsync(userId, gmailIds, Array.Empty<string>(), new[] { request.LabelId! }, ct);
break;
}
await _db.SaveChangesAsync(ct);
return Result<CleanupResultDto>.Success(new CleanupResultDto(request.Action, emails.Count, 0, errors));
}
catch (Exception ex)
{
_logger.LogError(ex, "Cleanup action {Action} failed for user {UserId}", request.Action, userId);
errors.Add(ex.Message);
return Result<CleanupResultDto>.Failure(ex.Message);
}
}
private async Task<List<Email>> ResolveTargetsAsync(Guid userId, CleanupRequestDto request, CancellationToken ct)
{
if (request.EmailIds is { Count: > 0 })
{
return await _db.Emails.Include(e => e.Sender)
.Where(e => e.UserId == userId && request.EmailIds.Contains(e.Id))
.ToListAsync(ct);
}
if (!string.IsNullOrWhiteSpace(request.Query))
{
var parsed = GmailQueryParser.Parse(request.Query, page: 1, pageSize: 10_000);
var ids = (await _search.SearchAsync(userId, parsed, ct)).Items.Select(i => i.Id).ToList();
return await _db.Emails.Include(e => e.Sender)
.Where(e => e.UserId == userId && ids.Contains(e.Id)).ToListAsync(ct);
}
return new List<Email>();
}
}
@@ -0,0 +1,136 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.Common;
using InboxIntel.Application.DTOs;
using InboxIntel.Domain.Entities;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Gmail;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace InboxIntel.Infrastructure.Cleanup;
/// <summary>
/// Detects unsubscribe opportunities from List-Unsubscribe headers, groups them
/// per sender, and processes a confirmed queue. HTTP one-click targets are
/// POSTed; mailto targets are surfaced to the user (we never auto-send mail).
/// </summary>
public class UnsubscribeService : IUnsubscribeService
{
private readonly AppDbContext _db;
private readonly IHttpClientFactory _httpFactory;
private readonly ILogger<UnsubscribeService> _logger;
public UnsubscribeService(AppDbContext db, IHttpClientFactory httpFactory, ILogger<UnsubscribeService> logger)
{
_db = db;
_httpFactory = httpFactory;
_logger = logger;
}
public async Task DetectAsync(Guid userId, CancellationToken ct = default)
{
// Latest unsubscribe-bearing email per sender.
var candidates = await _db.Emails
.Where(e => e.UserId == userId && e.HasListUnsubscribe)
.GroupBy(e => e.SenderId)
.Select(g => new
{
SenderId = g.Key,
Count = g.Count(),
Raw = g.OrderByDescending(e => e.SentAtUtc).Select(e => e.ListUnsubscribeRaw).FirstOrDefault(),
OneClick = g.Any(e => e.SupportsOneClickUnsubscribe)
})
.ToListAsync(ct);
foreach (var c in candidates)
{
var target = GmailMessageParser.ExtractUnsubscribeTarget(c.Raw);
var method = target is null ? UnsubscribeMethod.None
: target.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) ? UnsubscribeMethod.MailTo
: c.OneClick ? UnsubscribeMethod.OneClickPost
: UnsubscribeMethod.HttpLink;
var item = await _db.UnsubscribeItems.FirstOrDefaultAsync(u => u.UserId == userId && u.SenderId == c.SenderId, ct);
if (item is null)
{
item = new UnsubscribeItem { UserId = userId, SenderId = c.SenderId };
_db.UnsubscribeItems.Add(item);
}
item.Method = method;
item.UnsubscribeTarget = target;
item.EmailCount = c.Count;
if (item.Status == default) item.Status = UnsubscribeStatus.Detected;
}
await _db.SaveChangesAsync(ct);
}
public async Task<IReadOnlyList<UnsubscribeItemDto>> GetSafeToUnsubscribeAsync(Guid userId, CancellationToken ct = default)
{
return await _db.UnsubscribeItems
.Where(u => u.UserId == userId && u.Method != UnsubscribeMethod.None)
.OrderByDescending(u => u.EmailCount)
.Select(u => new UnsubscribeItemDto(
u.Id, u.Sender!.Address, u.Sender.Domain!.Name, u.Method, u.Status, u.EmailCount, u.UnsubscribeTarget))
.ToListAsync(ct);
}
public async Task<Result<CleanupResultDto>> ProcessQueueAsync(Guid userId, UnsubscribeRequestDto request, CancellationToken ct = default)
{
if (!request.Confirmed)
return Result<CleanupResultDto>.Failure("Unsubscribe actions require explicit confirmation.");
var items = await _db.UnsubscribeItems
.Where(u => u.UserId == userId && request.ItemIds.Contains(u.Id))
.ToListAsync(ct);
var http = _httpFactory.CreateClient("unsubscribe");
int ok = 0, fail = 0;
var errors = new List<string>();
foreach (var item in items)
{
item.LastAttemptUtc = DateTimeOffset.UtcNow;
try
{
switch (item.Method)
{
case UnsubscribeMethod.OneClickPost:
var post = await http.PostAsync(item.UnsubscribeTarget,
new StringContent("List-Unsubscribe=One-Click"), ct);
SetResult(item, post.IsSuccessStatusCode);
if (post.IsSuccessStatusCode) ok++; else fail++;
break;
case UnsubscribeMethod.HttpLink:
var get = await http.GetAsync(item.UnsubscribeTarget, ct);
SetResult(item, get.IsSuccessStatusCode);
if (get.IsSuccessStatusCode) ok++; else fail++;
break;
case UnsubscribeMethod.MailTo:
// We never auto-send email; flag for the user to action.
item.Status = UnsubscribeStatus.Skipped;
item.ResultMessage = "mailto unsubscribe must be sent manually.";
break;
default:
item.Status = UnsubscribeStatus.Skipped;
break;
}
}
catch (Exception ex)
{
fail++;
item.Status = UnsubscribeStatus.Failed;
item.ResultMessage = ex.Message;
errors.Add($"{item.UnsubscribeTarget}: {ex.Message}");
}
}
await _db.SaveChangesAsync(ct);
return Result<CleanupResultDto>.Success(new CleanupResultDto(CleanupActionType.RemoveLabel, ok, fail, errors));
}
private static void SetResult(UnsubscribeItem item, bool success)
{
item.Status = success ? UnsubscribeStatus.Succeeded : UnsubscribeStatus.Failed;
item.ResultMessage = success ? "OK" : "Non-success HTTP status.";
}
}
@@ -0,0 +1,43 @@
using InboxIntel.Domain.Enums;
namespace InboxIntel.Infrastructure.Configuration;
public class GoogleOAuthOptions
{
public const string SectionName = "GoogleOAuth";
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
/// <summary>Scopes requested. Gmail read + modify (no send).</summary>
public string[] Scopes { get; set; } =
{
"openid", "email", "profile",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.modify"
};
}
public class GmailSyncOptions
{
public const string SectionName = "GmailSync";
public int PageSize { get; set; } = 100;
public int MaxParallelism { get; set; } = 4;
public int MaxRetries { get; set; } = 5;
/// <summary>Base delay in ms for exponential backoff.</summary>
public int BackoffBaseMs { get; set; } = 500;
/// <summary>Cron-like daily sync hour (UTC) for the scheduled worker.</summary>
public int DailySyncHourUtc { get; set; } = 3;
}
public class AiOptions
{
public const string SectionName = "Ai";
public AiProviderMode Mode { get; set; } = AiProviderMode.Disabled;
// Ollama (local)
public string OllamaBaseUrl { get; set; } = "http://localhost:11434";
public string OllamaModel { get; set; } = "llama3.1";
// OpenAI (cloud, optional)
public string OpenAiApiKey { get; set; } = string.Empty;
public string OpenAiModel { get; set; } = "gpt-4o-mini";
}
@@ -0,0 +1,69 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Ai;
using InboxIntel.Infrastructure.Analytics;
using InboxIntel.Infrastructure.Cleanup;
using InboxIntel.Infrastructure.Configuration;
using InboxIntel.Infrastructure.Export;
using InboxIntel.Infrastructure.Gmail;
using InboxIntel.Infrastructure.Persistence;
using InboxIntel.Infrastructure.Search;
using InboxIntel.Infrastructure.Security;
using InboxIntel.Infrastructure.Sync;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace InboxIntel.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration config)
{
// EF Core / PostgreSQL
services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(config.GetConnectionString("Postgres"),
npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
// Options
services.Configure<GoogleOAuthOptions>(config.GetSection(GoogleOAuthOptions.SectionName));
services.Configure<GmailSyncOptions>(config.GetSection(GmailSyncOptions.SectionName));
services.Configure<AiOptions>(config.GetSection(AiOptions.SectionName));
// Security
services.AddSingleton<ITokenProtector, DataProtectionTokenProtector>();
// Gmail
services.AddScoped<GmailClientFactory>();
services.AddScoped<IGmailService, GmailApiService>();
// Core services
services.AddScoped<ISyncService, SyncService>();
services.AddScoped<IAnalyticsService, AnalyticsService>();
services.AddScoped<ISearchService, SearchService>();
services.AddScoped<ICleanupService, CleanupService>();
services.AddScoped<IUnsubscribeService, UnsubscribeService>();
services.AddScoped<IExportService, ExportService>();
// HTTP clients
services.AddHttpClient("unsubscribe", c => c.Timeout = TimeSpan.FromSeconds(15));
services.AddHttpClient("ollama");
services.AddHttpClient("openai");
// AI provider selected by configured mode.
var aiMode = config.GetSection(AiOptions.SectionName).GetValue<AiProviderMode>("Mode");
switch (aiMode)
{
case AiProviderMode.LocalOllama: services.AddScoped<IAiProvider, OllamaProvider>(); break;
case AiProviderMode.CloudOpenAi: services.AddScoped<IAiProvider, OpenAiProvider>(); break;
default: services.AddScoped<IAiProvider, NullAiProvider>(); break;
}
services.AddScoped<IAiService, AiService>();
// Background worker (daily incremental sync + aggregate refresh)
services.AddHostedService<GmailSyncWorker>();
return services;
}
}
@@ -0,0 +1,112 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using CsvHelper;
using InboxIntel.Application.Abstractions;
using DTOs = InboxIntel.Application.DTOs;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace InboxIntel.Infrastructure.Export;
/// <summary>
/// Builds inbox reports as PDF (QuestPDF), CSV (CsvHelper) or JSON. The report
/// covers the inbox summary, sender stats, cleanup suggestions and storage use.
/// </summary>
public class ExportService : IExportService
{
private readonly IAnalyticsService _analytics;
private readonly IAiService _ai;
public ExportService(IAnalyticsService analytics, IAiService ai)
{
_analytics = analytics;
_ai = ai;
QuestPDF.Settings.License = LicenseType.Community;
}
public async Task<(byte[] Content, string ContentType, string FileName)> ExportReportAsync(Guid userId, ExportFormat format, CancellationToken ct = default)
{
var health = await _analytics.GetInboxHealthAsync(userId, ct);
var topSenders = await _analytics.GetTopSendersAsync(userId, 25, ct);
var suggestions = await _ai.SuggestCleanupAsync(userId, ct);
var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmm");
return format switch
{
ExportFormat.Json => (BuildJson(health, topSenders, suggestions), "application/json", $"inbox-report-{stamp}.json"),
ExportFormat.Csv => (BuildCsv(topSenders), "text/csv", $"sender-stats-{stamp}.csv"),
_ => (BuildPdf(health, topSenders, suggestions), "application/pdf", $"inbox-report-{stamp}.pdf")
};
}
private static byte[] BuildJson(object health, object senders, object suggestions)
{
var payload = new { generatedUtc = DateTimeOffset.UtcNow, health, topSenders = senders, cleanupSuggestions = suggestions };
return JsonSerializer.SerializeToUtf8Bytes(payload, new JsonSerializerOptions { WriteIndented = true });
}
private static byte[] BuildCsv(IEnumerable<DTOs.SenderStatDto> senders)
{
using var ms = new MemoryStream();
using (var writer = new StreamWriter(ms, Encoding.UTF8, leaveOpen: true))
using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture))
{
csv.WriteRecords(senders);
}
return ms.ToArray();
}
private byte[] BuildPdf(DTOs.InboxHealthDto health, IReadOnlyList<DTOs.SenderStatDto> senders, IReadOnlyList<DTOs.AiCleanupSuggestionDto> suggestions)
{
var doc = Document.Create(container =>
{
container.Page(page =>
{
page.Margin(40);
page.Size(PageSizes.A4);
page.DefaultTextStyle(t => t.FontSize(10));
page.Header().Text("InboxIntel — Inbox Report").FontSize(20).Bold();
page.Content().PaddingVertical(10).Column(col =>
{
col.Item().Text($"Generated: {DateTime.UtcNow:u}").FontColor(Colors.Grey.Medium);
col.Item().PaddingTop(10).Text($"Inbox Health: {health.Score}/100 (Grade {health.Grade})").FontSize(14).Bold();
col.Item().Text($"Total emails: {health.TotalEmails:N0} Unread: {health.UnreadEmails:N0} Newsletters: {health.NewsletterCount:N0}");
col.Item().Text($"Estimated storage: {health.EstimatedStorageBytes / 1_048_576.0:N1} MB");
col.Item().PaddingTop(14).Text("Recommendations").FontSize(13).Bold();
foreach (var rec in health.Recommendations)
col.Item().Text($"• {rec}");
col.Item().PaddingTop(14).Text("Top Senders").FontSize(13).Bold();
col.Item().Table(table =>
{
table.ColumnsDefinition(c => { c.RelativeColumn(3); c.RelativeColumn(1); c.RelativeColumn(1); });
table.Header(h =>
{
h.Cell().Text("Sender").Bold();
h.Cell().Text("Emails").Bold();
h.Cell().Text("Unread").Bold();
});
foreach (var s in senders)
{
table.Cell().Text(s.Address);
table.Cell().Text(s.EmailCount.ToString("N0"));
table.Cell().Text(s.UnreadCount.ToString("N0"));
}
});
col.Item().PaddingTop(14).Text("Cleanup Suggestions").FontSize(13).Bold();
foreach (var sug in suggestions)
col.Item().Text($"• {sug.Title} — {sug.Rationale}");
});
page.Footer().AlignCenter().Text(t => { t.Span("InboxIntel • "); t.CurrentPageNumber(); t.Span(" / "); t.TotalPages(); });
});
});
return doc.GeneratePdf();
}
}
@@ -0,0 +1,57 @@
using Google.Apis.Auth.OAuth2;
using Google.Apis.Auth.OAuth2.Flows;
using Google.Apis.Auth.OAuth2.Responses;
using Google.Apis.Gmail.v1;
using Google.Apis.Services;
using InboxIntel.Application.Abstractions;
using InboxIntel.Infrastructure.Configuration;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace InboxIntel.Infrastructure.Gmail;
/// <summary>
/// Builds an authenticated <see cref="GmailService"/> for a user by decrypting
/// the stored refresh token and letting the Google client library handle access
/// token refresh.
/// </summary>
public class GmailClientFactory
{
private readonly AppDbContext _db;
private readonly ITokenProtector _protector;
private readonly GoogleOAuthOptions _oauth;
public GmailClientFactory(AppDbContext db, ITokenProtector protector, IOptions<GoogleOAuthOptions> oauth)
{
_db = db;
_protector = protector;
_oauth = oauth.Value;
}
public async Task<GmailService> CreateAsync(Guid userId, CancellationToken ct = default)
{
var user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == userId, ct)
?? throw new InvalidOperationException($"User {userId} not found.");
if (user.EncryptedRefreshToken is null)
throw new InvalidOperationException("User has no stored refresh token. Re-authentication required.");
var refreshToken = _protector.Unprotect(user.EncryptedRefreshToken);
var flow = new GoogleAuthorizationCodeFlow(new GoogleAuthorizationCodeFlow.Initializer
{
ClientSecrets = new ClientSecrets { ClientId = _oauth.ClientId, ClientSecret = _oauth.ClientSecret },
Scopes = _oauth.Scopes
});
var tokenResponse = new TokenResponse { RefreshToken = refreshToken };
var credential = new UserCredential(flow, user.Id.ToString(), tokenResponse);
return new GmailService(new BaseClientService.Initializer
{
HttpClientInitializer = credential,
ApplicationName = "InboxIntel"
});
}
}
@@ -0,0 +1,110 @@
using Google.Apis.Gmail.v1.Data;
using InboxIntel.Application.Abstractions;
using System.Text;
using System.Text.RegularExpressions;
namespace InboxIntel.Infrastructure.Gmail;
/// <summary>
/// Converts a raw Gmail <see cref="Message"/> into the structured
/// <see cref="GmailMessageDetail"/> the sync pipeline persists. Extracts the
/// sender, plain-text body, attachment metadata, and unsubscribe signals.
/// </summary>
public static class GmailMessageParser
{
private static readonly Regex FromRegex = new(@"^(?:(?<name>.*?)\s*)?<?(?<addr>[^<>\s]+@[^<>\s]+)>?$", RegexOptions.Compiled);
private static readonly Regex HttpLinkRegex = new(@"https?://[^>\s,]+", RegexOptions.Compiled);
public static GmailMessageDetail Parse(Message msg)
{
var headers = msg.Payload?.Headers ?? new List<MessagePartHeader>();
string GetHeader(string name) =>
headers.FirstOrDefault(h => string.Equals(h.Name, name, StringComparison.OrdinalIgnoreCase))?.Value ?? string.Empty;
var (fromName, fromAddr) = ParseFrom(GetHeader("From"));
var subject = GetHeader("Subject");
var listUnsub = GetHeader("List-Unsubscribe");
var listUnsubPost = GetHeader("List-Unsubscribe-Post");
var sentMs = msg.InternalDate ?? 0;
var sentAt = DateTimeOffset.FromUnixTimeMilliseconds(sentMs);
var labelIds = msg.LabelIds?.ToList() ?? new List<string>();
var isUnread = labelIds.Contains("UNREAD");
var attachments = new List<(string, string?, long, string?)>();
var bodyBuilder = new StringBuilder();
WalkParts(msg.Payload, bodyBuilder, attachments);
return new GmailMessageDetail(
GmailMessageId: msg.Id,
GmailThreadId: msg.ThreadId,
FromAddress: fromAddr,
FromDisplayName: string.IsNullOrWhiteSpace(fromName) ? null : fromName,
Subject: string.IsNullOrWhiteSpace(subject) ? null : subject,
Snippet: msg.Snippet,
BodyText: bodyBuilder.Length > 0 ? bodyBuilder.ToString() : null,
SentAtUtc: sentAt,
SizeEstimateBytes: msg.SizeEstimate ?? 0,
IsUnread: isUnread,
HasAttachments: attachments.Count > 0,
LabelIds: labelIds,
Attachments: attachments,
HasListUnsubscribe: !string.IsNullOrWhiteSpace(listUnsub),
ListUnsubscribeRaw: string.IsNullOrWhiteSpace(listUnsub) ? null : listUnsub,
SupportsOneClickUnsubscribe: listUnsubPost.Contains("One-Click", StringComparison.OrdinalIgnoreCase));
}
private static (string name, string addr) ParseFrom(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return (string.Empty, "unknown@unknown");
var m = FromRegex.Match(raw.Trim());
if (!m.Success) return (string.Empty, raw.Trim().ToLowerInvariant());
var name = m.Groups["name"].Value.Trim().Trim('"');
var addr = m.Groups["addr"].Value.Trim().ToLowerInvariant();
return (name, addr);
}
private static void WalkParts(MessagePart? part, StringBuilder body, List<(string, string?, long, string?)> attachments)
{
if (part is null) return;
var isAttachment = !string.IsNullOrEmpty(part.Filename) && part.Body?.AttachmentId is not null;
if (isAttachment)
{
attachments.Add((part.Filename!, part.MimeType, part.Body!.Size ?? 0, part.Body.AttachmentId));
}
else if (part.MimeType == "text/plain" && part.Body?.Data is not null && body.Length < 50_000)
{
body.Append(DecodeBase64Url(part.Body.Data));
}
if (part.Parts is not null)
foreach (var child in part.Parts)
WalkParts(child, body, attachments);
}
private static string DecodeBase64Url(string data)
{
var padded = data.Replace('-', '+').Replace('_', '/');
switch (padded.Length % 4) { case 2: padded += "=="; break; case 3: padded += "="; break; }
try { return Encoding.UTF8.GetString(Convert.FromBase64String(padded)); }
catch { return string.Empty; }
}
/// <summary>Extracts the first usable unsubscribe target from a List-Unsubscribe header.</summary>
public static string? ExtractUnsubscribeTarget(string? listUnsubscribeRaw)
{
if (string.IsNullOrWhiteSpace(listUnsubscribeRaw)) return null;
var http = HttpLinkRegex.Match(listUnsubscribeRaw);
if (http.Success) return http.Value;
var mailtoIdx = listUnsubscribeRaw.IndexOf("mailto:", StringComparison.OrdinalIgnoreCase);
if (mailtoIdx >= 0)
{
var rest = listUnsubscribeRaw[mailtoIdx..].TrimStart('<');
var end = rest.IndexOfAny(new[] { '>', ',', ' ' });
return end > 0 ? rest[..end] : rest;
}
return null;
}
}
@@ -0,0 +1,172 @@
using Google;
using Google.Apis.Gmail.v1;
using Google.Apis.Gmail.v1.Data;
using InboxIntel.Application.Abstractions;
using InboxIntel.Infrastructure.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Polly;
using Polly.Retry;
using System.Net;
using DomainLabel = InboxIntel.Domain.Entities.Label;
namespace InboxIntel.Infrastructure.Gmail;
/// <summary>
/// Gmail REST API wrapper. Every call is wrapped in a Polly retry pipeline that
/// applies exponential backoff with jitter on 429 / 5xx / transient errors,
/// honouring the configured max retry count. Responses are null-checked before use.
/// </summary>
public class GmailApiService : IGmailService
{
private readonly GmailClientFactory _factory;
private readonly GmailSyncOptions _options;
private readonly ILogger<GmailApiService> _logger;
private readonly ResiliencePipeline _pipeline;
public GmailApiService(GmailClientFactory factory, IOptions<GmailSyncOptions> options, ILogger<GmailApiService> logger)
{
_factory = factory;
_options = options.Value;
_logger = logger;
_pipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder()
.Handle<GoogleApiException>(IsTransient)
.Handle<HttpRequestException>(),
MaxRetryAttempts = _options.MaxRetries,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
Delay = TimeSpan.FromMilliseconds(_options.BackoffBaseMs),
OnRetry = args =>
{
_logger.LogWarning("Gmail API transient failure, retry {Attempt} after {Delay}ms",
args.AttemptNumber, args.RetryDelay.TotalMilliseconds);
return default;
}
})
.Build();
}
private static bool IsTransient(GoogleApiException ex) =>
ex.HttpStatusCode is HttpStatusCode.TooManyRequests
or HttpStatusCode.InternalServerError
or HttpStatusCode.BadGateway
or HttpStatusCode.ServiceUnavailable
or HttpStatusCode.GatewayTimeout;
public async Task<string> GetProfileHistoryIdAsync(Guid userId, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var profile = await _pipeline.ExecuteAsync(async token =>
await client.Users.GetProfile("me").ExecuteAsync(token), ct);
return profile?.HistoryId?.ToString() ?? throw new InvalidOperationException("Gmail profile returned no historyId.");
}
public async Task<GmailMessagePage> ListMessageIdsAsync(Guid userId, string? pageToken, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var page = await _pipeline.ExecuteAsync(async token =>
{
var req = client.Users.Messages.List("me");
req.MaxResults = _options.PageSize;
req.PageToken = pageToken;
req.IncludeSpamTrash = false;
return await req.ExecuteAsync(token);
}, ct);
var ids = page?.Messages?.Select(m => m.Id).Where(id => id is not null).ToList() ?? new List<string>();
return new GmailMessagePage(ids!, page?.NextPageToken, (int)(page?.ResultSizeEstimate ?? 0));
}
public async Task<GmailMessageDetail> GetMessageAsync(Guid userId, string gmailMessageId, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var msg = await _pipeline.ExecuteAsync(async token =>
{
var req = client.Users.Messages.Get("me", gmailMessageId);
req.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Full;
return await req.ExecuteAsync(token);
}, ct);
if (msg is null) throw new InvalidOperationException($"Gmail returned null for message {gmailMessageId}.");
return GmailMessageParser.Parse(msg);
}
public async Task<GmailHistoryPage> ListHistoryAsync(Guid userId, string startHistoryId, string? pageToken, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var history = await _pipeline.ExecuteAsync(async token =>
{
var req = client.Users.History.List("me");
req.StartHistoryId = ulong.Parse(startHistoryId);
req.PageToken = pageToken;
return await req.ExecuteAsync(token);
}, ct);
var changed = new List<string>();
var deleted = new List<string>();
foreach (var h in history?.History ?? Enumerable.Empty<History>())
{
if (h.MessagesAdded is not null) changed.AddRange(h.MessagesAdded.Select(m => m.Message.Id));
if (h.MessagesDeleted is not null) deleted.AddRange(h.MessagesDeleted.Select(m => m.Message.Id));
}
return new GmailHistoryPage(changed, deleted, history?.NextPageToken, history?.HistoryId?.ToString());
}
public async Task<IReadOnlyList<DomainLabel>> ListLabelsAsync(Guid userId, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var resp = await _pipeline.ExecuteAsync(async token =>
await client.Users.Labels.List("me").ExecuteAsync(token), ct);
return resp?.Labels?.Select(l => new DomainLabel
{
UserId = userId,
GmailLabelId = l.Id,
Name = l.Name,
Type = l.Type ?? "user"
}).ToList() ?? new List<DomainLabel>();
}
public async Task BatchModifyAsync(Guid userId, IEnumerable<string> messageIds, IEnumerable<string> addLabelIds, IEnumerable<string> removeLabelIds, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var body = new BatchModifyMessagesRequest
{
Ids = messageIds.ToList(),
AddLabelIds = addLabelIds.ToList(),
RemoveLabelIds = removeLabelIds.ToList()
};
await _pipeline.ExecuteAsync(async token =>
{
await client.Users.Messages.BatchModify(body, "me").ExecuteAsync(token);
return true;
}, ct);
}
public async Task BatchTrashAsync(Guid userId, IEnumerable<string> messageIds, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
foreach (var id in messageIds)
{
await _pipeline.ExecuteAsync(async token =>
{
await client.Users.Messages.Trash("me", id).ExecuteAsync(token);
return true;
}, ct);
}
}
public async Task BatchDeleteAsync(Guid userId, IEnumerable<string> messageIds, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var body = new BatchDeleteMessagesRequest { Ids = messageIds.ToList() };
await _pipeline.ExecuteAsync(async token =>
{
await client.Users.Messages.BatchDelete(body, "me").ExecuteAsync(token);
return true;
}, ct);
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>InboxIntel.Infrastructure</RootNamespace>
<AssemblyName>InboxIntel.Infrastructure</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Google.Apis.Gmail.v1" Version="1.68.0.3427" />
<PackageReference Include="Google.Apis.Auth" Version="1.68.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.7" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Polly" Version="8.4.1" />
<PackageReference Include="QuestPDF" Version="2024.7.0" />
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\InboxIntel.Application\InboxIntel.Application.csproj" />
<ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,41 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Common;
using InboxIntel.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using System.Reflection;
namespace InboxIntel.Infrastructure.Persistence;
public class AppDbContext : DbContext, IAppDbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<User> Users => Set<User>();
public DbSet<Email> Emails => Set<Email>();
public DbSet<MailThread> Threads => Set<MailThread>();
public DbSet<Sender> Senders => Set<Sender>();
public DbSet<MailDomain> Domains => Set<MailDomain>();
public DbSet<Attachment> Attachments => Set<Attachment>();
public DbSet<Label> Labels => Set<Label>();
public DbSet<EmailLabel> EmailLabels => Set<EmailLabel>();
public DbSet<SyncState> SyncStates => Set<SyncState>();
public DbSet<AnalyticsAggregate> AnalyticsAggregates => Set<AnalyticsAggregate>();
public DbSet<WidgetLayout> WidgetLayouts => Set<WidgetLayout>();
public DbSet<UnsubscribeItem> UnsubscribeItems => Set<UnsubscribeItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
base.OnModelCreating(modelBuilder);
}
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
foreach (var entry in ChangeTracker.Entries<AuditableEntity>())
{
if (entry.State == EntityState.Modified)
entry.Entity.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
return base.SaveChangesAsync(ct);
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace InboxIntel.Infrastructure.Persistence;
/// <summary>
/// Design-time factory so `dotnet ef migrations add ...` works without booting
/// the full API host. Reads the connection string from the EF_CONNECTION env
/// var, falling back to a local default.
/// </summary>
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var conn = Environment.GetEnvironmentVariable("EF_CONNECTION")
?? "Host=localhost;Port=5432;Database=inboxintel;Username=inboxintel;Password=inboxintel";
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(conn)
.Options;
return new AppDbContext(options);
}
}
@@ -0,0 +1,48 @@
using InboxIntel.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace InboxIntel.Infrastructure.Persistence.Configurations;
public class EmailConfiguration : IEntityTypeConfiguration<Email>
{
public void Configure(EntityTypeBuilder<Email> b)
{
b.ToTable("emails");
b.HasKey(e => e.Id);
b.Property(e => e.GmailMessageId).HasMaxLength(64).IsRequired();
b.Property(e => e.Subject).HasMaxLength(1024);
b.Property(e => e.Snippet).HasMaxLength(2048);
b.Property(e => e.ListUnsubscribeRaw).HasMaxLength(2048);
// One Gmail message per user.
b.HasIndex(e => new { e.UserId, e.GmailMessageId }).IsUnique();
// Indexes that power fast sender grouping, time-series, and inbox filters at 100k+ rows.
b.HasIndex(e => new { e.UserId, e.SenderId });
b.HasIndex(e => new { e.UserId, e.SentAtUtc });
b.HasIndex(e => new { e.UserId, e.IsUnread });
b.HasIndex(e => new { e.UserId, e.Category });
b.HasIndex(e => new { e.UserId, e.IsInInbox });
b.HasOne(e => e.Thread)
.WithMany(t => t.Emails)
.HasForeignKey(e => e.ThreadId)
.OnDelete(DeleteBehavior.Cascade);
b.HasOne(e => e.Sender)
.WithMany(s => s.Emails)
.HasForeignKey(e => e.SenderId)
.OnDelete(DeleteBehavior.Restrict);
// PostgreSQL full-text search: generated tsvector over subject + body,
// with a GIN index. Maintained by the database, read-only in code.
b.Property(e => e.SearchVector)
.HasColumnType("tsvector")
.HasComputedColumnSql(
"to_tsvector('english', coalesce(\"Subject\",'') || ' ' || coalesce(\"BodyText\",''))",
stored: true);
b.HasIndex(e => e.SearchVector).HasMethod("GIN");
}
}
@@ -0,0 +1,143 @@
using InboxIntel.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace InboxIntel.Infrastructure.Persistence.Configurations;
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> b)
{
b.ToTable("users");
b.HasKey(u => u.Id);
b.Property(u => u.GoogleSubjectId).HasMaxLength(64).IsRequired();
b.Property(u => u.Email).HasMaxLength(320).IsRequired();
b.HasIndex(u => u.GoogleSubjectId).IsUnique();
b.HasIndex(u => u.Email).IsUnique();
// EncryptedRefreshToken is bytea; never indexed, never logged.
}
}
public class MailDomainConfiguration : IEntityTypeConfiguration<MailDomain>
{
public void Configure(EntityTypeBuilder<MailDomain> b)
{
b.ToTable("domains");
b.HasKey(d => d.Id);
b.Property(d => d.Name).HasMaxLength(255).IsRequired();
b.HasIndex(d => new { d.UserId, d.Name }).IsUnique();
}
}
public class SenderConfiguration : IEntityTypeConfiguration<Sender>
{
public void Configure(EntityTypeBuilder<Sender> b)
{
b.ToTable("senders");
b.HasKey(s => s.Id);
b.Property(s => s.Address).HasMaxLength(320).IsRequired();
b.Property(s => s.DisplayName).HasMaxLength(255);
b.HasIndex(s => new { s.UserId, s.Address }).IsUnique();
b.HasIndex(s => new { s.UserId, s.EmailCount });
b.HasOne(s => s.Domain).WithMany(d => d.Senders)
.HasForeignKey(s => s.DomainId).OnDelete(DeleteBehavior.Restrict);
}
}
public class MailThreadConfiguration : IEntityTypeConfiguration<MailThread>
{
public void Configure(EntityTypeBuilder<MailThread> b)
{
b.ToTable("threads");
b.HasKey(t => t.Id);
b.Property(t => t.GmailThreadId).HasMaxLength(64).IsRequired();
b.Property(t => t.Subject).HasMaxLength(1024);
b.HasIndex(t => new { t.UserId, t.GmailThreadId }).IsUnique();
}
}
public class AttachmentConfiguration : IEntityTypeConfiguration<Attachment>
{
public void Configure(EntityTypeBuilder<Attachment> b)
{
b.ToTable("attachments");
b.HasKey(a => a.Id);
b.Property(a => a.FileName).HasMaxLength(512);
b.Property(a => a.MimeType).HasMaxLength(255);
b.HasIndex(a => new { a.UserId, a.MimeType });
b.HasOne(a => a.Email).WithMany(e => e.Attachments)
.HasForeignKey(a => a.EmailId).OnDelete(DeleteBehavior.Cascade);
}
}
public class LabelConfiguration : IEntityTypeConfiguration<Label>
{
public void Configure(EntityTypeBuilder<Label> b)
{
b.ToTable("labels");
b.HasKey(l => l.Id);
b.Property(l => l.GmailLabelId).HasMaxLength(64).IsRequired();
b.Property(l => l.Name).HasMaxLength(255).IsRequired();
b.HasIndex(l => new { l.UserId, l.GmailLabelId }).IsUnique();
}
}
public class EmailLabelConfiguration : IEntityTypeConfiguration<EmailLabel>
{
public void Configure(EntityTypeBuilder<EmailLabel> b)
{
b.ToTable("email_labels");
b.HasKey(el => new { el.EmailId, el.LabelId });
b.HasOne(el => el.Email).WithMany(e => e.EmailLabels)
.HasForeignKey(el => el.EmailId).OnDelete(DeleteBehavior.Cascade);
b.HasOne(el => el.Label).WithMany(l => l.EmailLabels)
.HasForeignKey(el => el.LabelId).OnDelete(DeleteBehavior.Cascade);
}
}
public class SyncStateConfiguration : IEntityTypeConfiguration<SyncState>
{
public void Configure(EntityTypeBuilder<SyncState> b)
{
b.ToTable("sync_states");
b.HasKey(s => s.Id);
b.HasIndex(s => s.UserId).IsUnique();
b.Property(s => s.LastError).HasMaxLength(4000);
}
}
public class AnalyticsAggregateConfiguration : IEntityTypeConfiguration<AnalyticsAggregate>
{
public void Configure(EntityTypeBuilder<AnalyticsAggregate> b)
{
b.ToTable("analytics_aggregates");
b.HasKey(a => a.Id);
b.HasIndex(a => new { a.UserId, a.Day }).IsUnique();
}
}
public class WidgetLayoutConfiguration : IEntityTypeConfiguration<WidgetLayout>
{
public void Configure(EntityTypeBuilder<WidgetLayout> b)
{
b.ToTable("widget_layouts");
b.HasKey(w => w.Id);
b.Property(w => w.WidgetKey).HasMaxLength(64).IsRequired();
b.HasIndex(w => new { w.UserId, w.WidgetKey }).IsUnique();
b.HasOne<User>().WithMany(u => u.WidgetLayouts)
.HasForeignKey(w => w.UserId).OnDelete(DeleteBehavior.Cascade);
}
}
public class UnsubscribeItemConfiguration : IEntityTypeConfiguration<UnsubscribeItem>
{
public void Configure(EntityTypeBuilder<UnsubscribeItem> b)
{
b.ToTable("unsubscribe_items");
b.HasKey(u => u.Id);
b.Property(u => u.UnsubscribeTarget).HasMaxLength(2048);
b.HasIndex(u => new { u.UserId, u.SenderId }).IsUnique();
b.HasOne(u => u.Sender).WithMany()
.HasForeignKey(u => u.SenderId).OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,59 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.Common;
using InboxIntel.Application.DTOs;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace InboxIntel.Infrastructure.Search;
/// <summary>
/// Structured + full-text search. Structured filters compose as SQL WHERE
/// clauses; free text uses PostgreSQL FTS via the generated SearchVector column
/// (EF.Functions.ToTsVector/Matches translate to @@ / to_tsquery).
/// </summary>
public class SearchService : ISearchService
{
private readonly AppDbContext _db;
public SearchService(AppDbContext db) => _db = db;
public async Task<PagedResult<EmailSummaryDto>> SearchAsync(Guid userId, SearchRequestDto r, CancellationToken ct = default)
{
var q = _db.Emails.AsNoTracking().Where(e => e.UserId == userId);
if (!string.IsNullOrWhiteSpace(r.Sender))
q = q.Where(e => e.Sender!.Address.Contains(r.Sender) || e.Sender.DisplayName!.Contains(r.Sender));
if (!string.IsNullOrWhiteSpace(r.Domain))
q = q.Where(e => e.Sender!.Domain!.Name == r.Domain);
if (r.From is { } from)
q = q.Where(e => e.SentAtUtc >= new DateTimeOffset(from.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero));
if (r.To is { } to)
q = q.Where(e => e.SentAtUtc <= new DateTimeOffset(to.ToDateTime(TimeOnly.MaxValue), TimeSpan.Zero));
if (r.IsUnread is { } unread)
q = q.Where(e => e.IsUnread == unread);
if (r.HasAttachments is { } att)
q = q.Where(e => e.HasAttachments == att);
if (!string.IsNullOrWhiteSpace(r.Query))
{
// PostgreSQL full-text match against the generated tsvector.
var term = r.Query.Trim();
q = q.Where(e => e.SearchVector!.Matches(EF.Functions.PlainToTsQuery("english", term)));
}
var total = await q.CountAsync(ct);
var items = await q
.OrderByDescending(e => e.SentAtUtc)
.Skip((r.Page - 1) * r.PageSize)
.Take(r.PageSize)
.Select(e => new EmailSummaryDto(
e.Id, e.GmailMessageId, e.Subject, e.Snippet,
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
e.IsUnread, e.HasAttachments, e.SizeEstimateBytes, e.Category))
.ToListAsync(ct);
return new PagedResult<EmailSummaryDto>
{
Items = items, Page = r.Page, PageSize = r.PageSize, TotalCount = total
};
}
}
@@ -0,0 +1,25 @@
using InboxIntel.Application.Abstractions;
using Microsoft.AspNetCore.DataProtection;
using System.Text;
namespace InboxIntel.Infrastructure.Security;
/// <summary>
/// Encrypts OAuth refresh tokens at rest using the ASP.NET Core Data Protection
/// API (AES-256-CBC + HMAC). Keys are persisted to a protected key ring so
/// tokens survive restarts. Plaintext tokens are never logged.
/// </summary>
public class DataProtectionTokenProtector : ITokenProtector
{
private const string Purpose = "InboxIntel.OAuthRefreshToken.v1";
private readonly IDataProtector _protector;
public DataProtectionTokenProtector(IDataProtectionProvider provider)
=> _protector = provider.CreateProtector(Purpose);
public byte[] Protect(string plaintext)
=> _protector.Protect(Encoding.UTF8.GetBytes(plaintext));
public string Unprotect(byte[] ciphertext)
=> Encoding.UTF8.GetString(_protector.Unprotect(ciphertext));
}
@@ -0,0 +1,74 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Infrastructure.Configuration;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace InboxIntel.Infrastructure.Sync;
/// <summary>
/// Background worker that runs a daily incremental sync for every user and
/// refreshes analytics aggregates. Non-blocking: it runs in its own scope and
/// never touches the request pipeline. Failures are logged and retried on the
/// next tick rather than crashing the host.
/// </summary>
public class GmailSyncWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly GmailSyncOptions _options;
private readonly ILogger<GmailSyncWorker> _logger;
public GmailSyncWorker(IServiceScopeFactory scopeFactory, IOptions<GmailSyncOptions> options, ILogger<GmailSyncWorker> logger)
{
_scopeFactory = scopeFactory;
_options = options.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("GmailSyncWorker started; daily sync hour = {Hour}:00 UTC", _options.DailySyncHourUtc);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var now = DateTimeOffset.UtcNow;
if (now.Hour == _options.DailySyncHourUtc)
await RunForAllUsersAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "GmailSyncWorker tick failed");
}
// Re-evaluate hourly. A production deployment may swap this for Hangfire/cron.
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
private async Task RunForAllUsersAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var sync = scope.ServiceProvider.GetRequiredService<ISyncService>();
var analytics = scope.ServiceProvider.GetRequiredService<IAnalyticsService>();
var userIds = await db.Users.Select(u => u.Id).ToListAsync(ct);
foreach (var userId in userIds)
{
try
{
await sync.RunIncrementalSyncAsync(userId, ct);
await analytics.RefreshAggregatesAsync(userId, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Scheduled sync failed for user {UserId}", userId);
}
}
}
}
@@ -0,0 +1,28 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Enums;
namespace InboxIntel.Infrastructure.Sync;
/// <summary>
/// Fast, dependency-free first-pass classifier applied during sync. The AI
/// layer can later refine these labels, but this guarantees every email has a
/// sensible category even when AI is disabled.
/// </summary>
public static class HeuristicClassifier
{
private static readonly string[] FinanceHints = { "invoice", "receipt", "payment", "statement", "bank", "transaction", "billing" };
private static readonly string[] SocialHints = { "facebook", "twitter", "linkedin", "instagram", "tiktok" };
private static readonly string[] NoReplyHints = { "noreply", "no-reply", "donotreply", "newsletter", "mailer", "notifications" };
public static EmailCategory Classify(GmailMessageDetail d)
{
var subject = (d.Subject ?? string.Empty).ToLowerInvariant();
var from = d.FromAddress.ToLowerInvariant();
if (d.HasListUnsubscribe) return EmailCategory.Newsletter;
if (FinanceHints.Any(h => subject.Contains(h))) return EmailCategory.Finance;
if (SocialHints.Any(h => from.Contains(h))) return EmailCategory.Social;
if (NoReplyHints.Any(h => from.Contains(h))) return EmailCategory.Notification;
return EmailCategory.Personal;
}
}
@@ -0,0 +1,246 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Entities;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Gmail;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace InboxIntel.Infrastructure.Sync;
/// <summary>
/// Orchestrates Gmail synchronisation. Full sync pages through every message;
/// incremental sync replays the Gmail history feed since the last historyId.
/// Progress is checkpointed to <see cref="SyncState"/> so an interrupted run
/// resumes from its last page token instead of restarting.
/// </summary>
public class SyncService : ISyncService
{
private readonly AppDbContext _db;
private readonly IGmailService _gmail;
private readonly ILogger<SyncService> _logger;
public SyncService(AppDbContext db, IGmailService gmail, ILogger<SyncService> logger)
{
_db = db;
_gmail = gmail;
_logger = logger;
}
public async Task<SyncStatus> GetStatusAsync(Guid userId, CancellationToken ct = default)
=> (await GetOrCreateStateAsync(userId, ct)).Status;
public async Task RunFullSyncAsync(Guid userId, CancellationToken ct = default)
{
var state = await GetOrCreateStateAsync(userId, ct);
state.Status = SyncStatus.Running;
state.LastSyncType = SyncType.Full;
state.StartedUtc = DateTimeOffset.UtcNow;
state.LastError = null;
await _db.SaveChangesAsync(ct);
try
{
await SyncLabelsAsync(userId, ct);
string? pageToken = state.ResumePageToken; // resume support
do
{
var page = await _gmail.ListMessageIdsAsync(userId, pageToken, ct);
foreach (var messageId in page.MessageIds)
{
if (await _db.Emails.AnyAsync(e => e.UserId == userId && e.GmailMessageId == messageId, ct))
continue;
var detail = await _gmail.GetMessageAsync(userId, messageId, ct);
await UpsertMessageAsync(userId, detail, ct);
state.MessagesProcessed++;
}
pageToken = page.NextPageToken;
state.ResumePageToken = pageToken; // checkpoint
state.TotalMessagesEstimate = page.ResultSizeEstimate;
await _db.SaveChangesAsync(ct);
}
while (pageToken is not null && !ct.IsCancellationRequested);
state.LastHistoryId = await _gmail.GetProfileHistoryIdAsync(userId, ct);
state.ResumePageToken = null;
state.Status = SyncStatus.Completed;
state.CompletedUtc = DateTimeOffset.UtcNow;
state.LastSuccessfulSyncUtc = DateTimeOffset.UtcNow;
state.ConsecutiveFailures = 0;
await _db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
await MarkFailedAsync(state, ex, ct);
throw;
}
}
public async Task RunIncrementalSyncAsync(Guid userId, CancellationToken ct = default)
{
var state = await GetOrCreateStateAsync(userId, ct);
if (string.IsNullOrEmpty(state.LastHistoryId))
{
await RunFullSyncAsync(userId, ct); // no watermark yet -> full sync
return;
}
state.Status = SyncStatus.Running;
state.LastSyncType = SyncType.Incremental;
state.StartedUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(ct);
try
{
string? pageToken = null;
string? newHistoryId = state.LastHistoryId;
do
{
var page = await _gmail.ListHistoryAsync(userId, state.LastHistoryId!, pageToken, ct);
foreach (var id in page.ChangedMessageIds.Distinct())
{
var detail = await _gmail.GetMessageAsync(userId, id, ct);
await UpsertMessageAsync(userId, detail, ct);
}
foreach (var id in page.DeletedMessageIds.Distinct())
{
var existing = await _db.Emails.FirstOrDefaultAsync(e => e.UserId == userId && e.GmailMessageId == id, ct);
if (existing is not null) _db.Emails.Remove(existing);
}
if (page.NewHistoryId is not null) newHistoryId = page.NewHistoryId;
pageToken = page.NextPageToken;
await _db.SaveChangesAsync(ct);
}
while (pageToken is not null && !ct.IsCancellationRequested);
state.LastHistoryId = newHistoryId;
state.Status = SyncStatus.Completed;
state.CompletedUtc = DateTimeOffset.UtcNow;
state.LastSuccessfulSyncUtc = DateTimeOffset.UtcNow;
state.ConsecutiveFailures = 0;
await _db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
await MarkFailedAsync(state, ex, ct);
throw;
}
}
private async Task MarkFailedAsync(SyncState state, Exception ex, CancellationToken ct)
{
_logger.LogError(ex, "Sync failed for user {UserId}", state.UserId);
state.Status = SyncStatus.Failed;
state.ConsecutiveFailures++;
state.LastError = ex.Message;
await _db.SaveChangesAsync(ct);
}
private async Task<SyncState> GetOrCreateStateAsync(Guid userId, CancellationToken ct)
{
var state = await _db.SyncStates.FirstOrDefaultAsync(s => s.UserId == userId, ct);
if (state is null)
{
state = new SyncState { UserId = userId };
_db.SyncStates.Add(state);
await _db.SaveChangesAsync(ct);
}
return state;
}
private async Task SyncLabelsAsync(Guid userId, CancellationToken ct)
{
var remote = await _gmail.ListLabelsAsync(userId, ct);
foreach (var label in remote)
{
var existing = await _db.Labels.FirstOrDefaultAsync(l => l.UserId == userId && l.GmailLabelId == label.GmailLabelId, ct);
if (existing is null) _db.Labels.Add(label);
else { existing.Name = label.Name; existing.Type = label.Type; }
}
await _db.SaveChangesAsync(ct);
}
/// <summary>Resolves domain/sender/thread, then inserts the email and attachment metadata.</summary>
private async Task UpsertMessageAsync(Guid userId, GmailMessageDetail d, CancellationToken ct)
{
var sender = await ResolveSenderAsync(userId, d.FromAddress, d.FromDisplayName, ct);
var thread = await ResolveThreadAsync(userId, d.GmailThreadId, d.Subject, d.Snippet, d.SentAtUtc, ct);
var email = new Email
{
UserId = userId,
GmailMessageId = d.GmailMessageId,
ThreadId = thread.Id,
SenderId = sender.Id,
Subject = d.Subject,
Snippet = d.Snippet,
BodyText = d.BodyText,
SentAtUtc = d.SentAtUtc,
ReceivedAtUtc = d.SentAtUtc,
SizeEstimateBytes = d.SizeEstimateBytes,
IsUnread = d.IsUnread,
IsInInbox = d.LabelIds.Contains("INBOX"),
IsStarred = d.LabelIds.Contains("STARRED"),
IsImportant = d.LabelIds.Contains("IMPORTANT"),
HasAttachments = d.HasAttachments,
HasListUnsubscribe = d.HasListUnsubscribe,
ListUnsubscribeRaw = d.ListUnsubscribeRaw,
SupportsOneClickUnsubscribe = d.SupportsOneClickUnsubscribe,
Category = HeuristicClassifier.Classify(d)
};
_db.Emails.Add(email);
foreach (var (fileName, mime, size, attId) in d.Attachments)
{
_db.Attachments.Add(new Attachment
{
UserId = userId, EmailId = email.Id, FileName = fileName,
MimeType = mime, SizeBytes = size, GmailAttachmentId = attId
});
}
// Maintain sender rollups for fast grouping.
sender.EmailCount++;
if (d.IsUnread) sender.UnreadCount++;
sender.TotalSizeBytes += d.SizeEstimateBytes;
sender.LastReceivedUtc = d.SentAtUtc;
if (d.HasListUnsubscribe) sender.HasUnsubscribe = true;
thread.MessageCount++;
thread.LastMessageUtc = d.SentAtUtc;
}
private async Task<Sender> ResolveSenderAsync(Guid userId, string address, string? displayName, CancellationToken ct)
{
var sender = await _db.Senders.FirstOrDefaultAsync(s => s.UserId == userId && s.Address == address, ct);
if (sender is not null) return sender;
var domainName = address.Contains('@') ? address[(address.IndexOf('@') + 1)..] : "unknown";
var domain = await _db.Domains.FirstOrDefaultAsync(x => x.UserId == userId && x.Name == domainName, ct);
if (domain is null)
{
domain = new MailDomain { UserId = userId, Name = domainName };
_db.Domains.Add(domain);
}
domain.EmailCount++;
sender = new Sender { UserId = userId, Address = address, DisplayName = displayName, Domain = domain, DomainId = domain.Id };
_db.Senders.Add(sender);
return sender;
}
private async Task<MailThread> ResolveThreadAsync(Guid userId, string gmailThreadId, string? subject, string? snippet, DateTimeOffset sentAt, CancellationToken ct)
{
var thread = await _db.Threads.FirstOrDefaultAsync(t => t.UserId == userId && t.GmailThreadId == gmailThreadId, ct);
if (thread is not null) return thread;
thread = new MailThread
{
UserId = userId, GmailThreadId = gmailThreadId, Subject = subject,
Snippet = snippet, FirstMessageUtc = sentAt
};
_db.Threads.Add(thread);
return thread;
}
}
@@ -0,0 +1,53 @@
using System.Net;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Xunit;
namespace InboxIntel.IntegrationTests;
/// <summary>
/// Test host that disables startup auto-migration so the factory boots without
/// a live PostgreSQL instance. The auth tests below never touch the database
/// (the challenge happens in middleware before any controller runs).
/// </summary>
public class TestAppFactory : WebApplicationFactory<Program>
{
protected override IHost CreateHost(IHostBuilder builder)
{
builder.ConfigureHostConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary<string, string?>
{
["Database:AutoMigrate"] = "false"
}));
return base.CreateHost(builder);
}
}
/// <summary>
/// Smoke tests proving the host boots and authorization is enforced. A fuller
/// suite would swap PostgreSQL for a Testcontainers instance and the Gmail
/// client for a fake, then exercise sync -> analytics end to end.
/// </summary>
public class AuthEndpointsTests : IClassFixture<TestAppFactory>
{
private readonly TestAppFactory _factory;
public AuthEndpointsTests(TestAppFactory factory) => _factory = factory;
[Fact]
public async Task Protected_endpoint_challenges_when_anonymous()
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
var resp = await client.GetAsync("/api/v1/analytics/dashboard");
// Unauthenticated -> redirect to Google challenge (302) or 401.
resp.StatusCode.Should().BeOneOf(HttpStatusCode.Found, HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Login_endpoint_is_anonymous()
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
var resp = await client.GetAsync("/api/v1/auth/login");
resp.StatusCode.Should().Be(HttpStatusCode.Redirect); // 302 to Google
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\InboxIntel.Api\InboxIntel.Api.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,41 @@
using FluentAssertions;
using InboxIntel.Application.Search;
using Xunit;
namespace InboxIntel.UnitTests;
public class GmailQueryParserTests
{
[Fact]
public void Parses_operators_and_free_text()
{
var r = GmailQueryParser.Parse("from:github.com is:unread has:attachment quarterly report");
r.Sender.Should().Be("github.com");
r.IsUnread.Should().BeTrue();
r.HasAttachments.Should().BeTrue();
r.Query.Should().Be("quarterly report");
}
[Fact]
public void Parses_date_range()
{
var r = GmailQueryParser.Parse("after:2025-01-01 before:2025-02-01");
r.From.Should().Be(new DateOnly(2025, 1, 1));
r.To.Should().Be(new DateOnly(2025, 2, 1));
}
[Fact]
public void Empty_query_yields_empty_filters()
{
var r = GmailQueryParser.Parse(" ");
r.Sender.Should().BeNull();
r.Query.Should().BeNull();
}
[Fact]
public void Is_read_sets_unread_false()
{
GmailQueryParser.Parse("is:read").IsUnread.Should().BeFalse();
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\InboxIntel.Application\InboxIntel.Application.csproj" />
<ProjectReference Include="..\..\src\InboxIntel.Infrastructure\InboxIntel.Infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,28 @@
using FluentAssertions;
using InboxIntel.Infrastructure.Gmail;
using Xunit;
namespace InboxIntel.UnitTests;
public class UnsubscribeExtractionTests
{
[Fact]
public void Prefers_http_link()
{
var raw = "<mailto:unsub@x.com>, <https://x.com/unsub?id=42>";
GmailMessageParser.ExtractUnsubscribeTarget(raw).Should().Be("https://x.com/unsub?id=42");
}
[Fact]
public void Falls_back_to_mailto()
{
var raw = "<mailto:unsubscribe@news.example.com>";
GmailMessageParser.ExtractUnsubscribeTarget(raw).Should().Be("mailto:unsubscribe@news.example.com");
}
[Fact]
public void Null_when_absent()
{
GmailMessageParser.ExtractUnsubscribeTarget(null).Should().BeNull();
}
}