chore: init project
This commit is contained in:
@@ -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
@@ -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/
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
# Backend API target for the Vite dev proxy.
|
||||
VITE_API_TARGET=http://localhost:5080
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>; }
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 & {ACTIONS.find((a) => a.v === action)?.label}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && <div className="card success">Done: {result.succeededCount} succeeded, {result.failedCount} failed.</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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 <-> 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>();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user