chore: init project

This commit is contained in:
cesnimda
2026-06-30 15:53:32 +02:00
commit f43ef5f945
94 changed files with 4405 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
using System.Security.Claims;
using InboxIntel.Application.Abstractions;
namespace InboxIntel.Api.Auth;
/// <summary>Resolves the authenticated user's id from the cookie principal.</summary>
public class CurrentUser : ICurrentUser
{
private readonly IHttpContextAccessor _accessor;
public CurrentUser(IHttpContextAccessor accessor) => _accessor = accessor;
public bool IsAuthenticated => _accessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false;
public Guid UserId
{
get
{
var raw = _accessor.HttpContext?.User?.FindFirstValue("inboxintel:uid");
return Guid.TryParse(raw, out var id) ? id : Guid.Empty;
}
}
}
@@ -0,0 +1,54 @@
using System.Security.Claims;
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Entities;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.EntityFrameworkCore;
namespace InboxIntel.Api.Auth;
/// <summary>
/// On successful Google sign-in: upsert the user, encrypt and persist the
/// refresh token, and attach the internal user id as a claim so downstream
/// requests resolve the correct tenant.
/// </summary>
public static class GoogleAuthEvents
{
public static async Task OnCreatingTicketAsync(OAuthCreatingTicketContext context)
{
var sp = context.HttpContext.RequestServices;
var db = sp.GetRequiredService<AppDbContext>();
var protector = sp.GetRequiredService<ITokenProtector>();
var principal = context.Principal!;
var sub = principal.FindFirstValue(ClaimTypes.NameIdentifier)!;
var email = principal.FindFirstValue(ClaimTypes.Email) ?? string.Empty;
var name = principal.FindFirstValue(ClaimTypes.Name);
var refreshToken = context.RefreshToken;
var user = await db.Users.FirstOrDefaultAsync(u => u.GoogleSubjectId == sub);
if (user is null)
{
user = new User { GoogleSubjectId = sub, Email = email, DisplayName = name };
db.Users.Add(user);
}
else
{
user.Email = email;
user.DisplayName = name;
}
// Only overwrite the stored token when Google returns a new one.
if (!string.IsNullOrEmpty(refreshToken))
user.EncryptedRefreshToken = protector.Protect(refreshToken);
user.AccessTokenExpiresAtUtc = context.ExpiresIn is { } exp
? DateTimeOffset.UtcNow.Add(exp) : null;
user.LastLoginUtc = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
// Internal id used by ICurrentUser.
context.Identity!.AddClaim(new Claim("inboxintel:uid", user.Id.ToString()));
}
}
@@ -0,0 +1,33 @@
using InboxIntel.Application.Abstractions;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
/// <summary>
/// AI endpoints are read-only / advisory. They never trigger destructive
/// actions - suggestions are returned for the user to act on via /cleanup.
/// </summary>
public class AiController : ApiControllerBase
{
private readonly IAiService _ai;
public AiController(IAiService ai) => _ai = ai;
[HttpGet("status")]
public IActionResult Status() => Ok(new { enabled = _ai.IsEnabled });
[HttpPost("classify/{emailId:guid}")]
public async Task<IActionResult> Classify(Guid emailId, CancellationToken ct)
=> Ok(await _ai.ClassifyAsync(UserId, emailId, ct));
[HttpGet("summary")]
public async Task<IActionResult> Summary(CancellationToken ct)
=> Ok(await _ai.SummarizeInboxAsync(UserId, ct));
[HttpGet("suggestions")]
public async Task<IActionResult> Suggestions(CancellationToken ct)
=> Ok(await _ai.SuggestCleanupAsync(UserId, ct));
[HttpPost("generate-query")]
public async Task<IActionResult> GenerateQuery([FromBody] string naturalLanguage, CancellationToken ct)
=> Ok(await _ai.GenerateQueryAsync(UserId, naturalLanguage, ct));
}
@@ -0,0 +1,30 @@
using InboxIntel.Application.Abstractions;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
public class AnalyticsController : ApiControllerBase
{
private readonly IAnalyticsService _analytics;
public AnalyticsController(IAnalyticsService analytics) => _analytics = analytics;
[HttpGet("dashboard")]
public async Task<IActionResult> Dashboard(CancellationToken ct) => Ok(await _analytics.GetDashboardAsync(UserId, ct));
[HttpGet("health")]
public async Task<IActionResult> Health(CancellationToken ct) => Ok(await _analytics.GetInboxHealthAsync(UserId, ct));
[HttpGet("top-senders")]
public async Task<IActionResult> TopSenders([FromQuery] int take = 20, CancellationToken ct = default)
=> Ok(await _analytics.GetTopSendersAsync(UserId, take, ct));
[HttpGet("volume")]
public async Task<IActionResult> Volume([FromQuery] int days = 90, CancellationToken ct = default)
=> Ok(await _analytics.GetVolumeOverTimeAsync(UserId, days, ct));
[HttpGet("heatmap")]
public async Task<IActionResult> Heatmap(CancellationToken ct) => Ok(await _analytics.GetHeatmapAsync(UserId, ct));
[HttpGet("attachments")]
public async Task<IActionResult> Attachments(CancellationToken ct) => Ok(await _analytics.GetAttachmentBreakdownAsync(UserId, ct));
}
@@ -0,0 +1,17 @@
using Asp.Versioning;
using InboxIntel.Application.Abstractions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
[ApiController]
[Authorize]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public abstract class ApiControllerBase : ControllerBase
{
private ICurrentUser? _currentUser;
protected ICurrentUser CurrentUser => _currentUser ??= HttpContext.RequestServices.GetRequiredService<ICurrentUser>();
protected Guid UserId => CurrentUser.UserId;
}
@@ -0,0 +1,39 @@
using System.Security.Claims;
using Asp.Versioning;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class AuthController : ControllerBase
{
/// <summary>Begins the Google OAuth2 login flow.</summary>
[HttpGet("login")]
[AllowAnonymous]
public IActionResult Login([FromQuery] string? returnUrl = "/")
=> Challenge(new AuthenticationProperties { RedirectUri = returnUrl }, GoogleDefaults.AuthenticationScheme);
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return NoContent();
}
/// <summary>Returns the currently signed-in user, or 401.</summary>
[HttpGet("me")]
[Authorize]
public IActionResult Me() => Ok(new
{
UserId = User.FindFirstValue("inboxintel:uid"),
Email = User.FindFirstValue(ClaimTypes.Email),
Name = User.FindFirstValue(ClaimTypes.Name)
});
}
@@ -0,0 +1,24 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.DTOs;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
public class CleanupController : ApiControllerBase
{
private readonly ICleanupService _cleanup;
public CleanupController(ICleanupService cleanup) => _cleanup = cleanup;
/// <summary>Preview which emails a cleanup action would affect. Always call before execute.</summary>
[HttpPost("preview")]
public async Task<IActionResult> Preview([FromBody] CleanupRequestDto request, CancellationToken ct)
=> Ok(await _cleanup.PreviewAsync(UserId, request, ct));
/// <summary>Execute a cleanup action. Destructive actions require Confirmed = true.</summary>
[HttpPost("execute")]
public async Task<IActionResult> Execute([FromBody] CleanupRequestDto request, CancellationToken ct)
{
var result = await _cleanup.ExecuteAsync(UserId, request, ct);
return result.Succeeded ? Ok(result.Value) : BadRequest(new { error = result.Error });
}
}
@@ -0,0 +1,24 @@
using InboxIntel.Application.Abstractions;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
public class ExportController : ApiControllerBase
{
private readonly IExportService _export;
public ExportController(IExportService export) => _export = export;
/// <summary>Export an inbox report. format = pdf | csv | json.</summary>
[HttpGet("report")]
public async Task<IActionResult> Report([FromQuery] string format = "pdf", CancellationToken ct = default)
{
var fmt = format.ToLowerInvariant() switch
{
"csv" => ExportFormat.Csv,
"json" => ExportFormat.Json,
_ => ExportFormat.Pdf
};
var (content, contentType, fileName) = await _export.ExportReportAsync(UserId, fmt, ct);
return File(content, contentType, fileName);
}
}
@@ -0,0 +1,25 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.DTOs;
using InboxIntel.Application.Search;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
public class SearchController : ApiControllerBase
{
private readonly ISearchService _search;
public SearchController(ISearchService search) => _search = search;
/// <summary>Structured search via JSON body.</summary>
[HttpPost]
public async Task<IActionResult> Search([FromBody] SearchRequestDto request, CancellationToken ct)
=> Ok(await _search.SearchAsync(UserId, request, ct));
/// <summary>Gmail-like query string search, e.g. ?q=from:github.com is:unread.</summary>
[HttpGet]
public async Task<IActionResult> Query([FromQuery] string? q, [FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken ct = default)
{
var parsed = GmailQueryParser.Parse(q, page, pageSize);
return Ok(await _search.SearchAsync(UserId, parsed, ct));
}
}
@@ -0,0 +1,29 @@
using InboxIntel.Application.Abstractions;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
public class SyncController : ApiControllerBase
{
private readonly ISyncService _sync;
public SyncController(ISyncService sync) => _sync = sync;
[HttpGet("status")]
public async Task<IActionResult> Status(CancellationToken ct)
=> Ok(new { status = (await _sync.GetStatusAsync(UserId, ct)).ToString() });
/// <summary>Triggers a full inbox sync (runs in the background task queue in production).</summary>
[HttpPost("full")]
public async Task<IActionResult> Full(CancellationToken ct)
{
await _sync.RunFullSyncAsync(UserId, ct);
return Accepted();
}
[HttpPost("incremental")]
public async Task<IActionResult> Incremental(CancellationToken ct)
{
await _sync.RunIncrementalSyncAsync(UserId, ct);
return Accepted();
}
}
@@ -0,0 +1,31 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.DTOs;
using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
public class UnsubscribeController : ApiControllerBase
{
private readonly IUnsubscribeService _unsub;
public UnsubscribeController(IUnsubscribeService unsub) => _unsub = unsub;
[HttpPost("detect")]
public async Task<IActionResult> Detect(CancellationToken ct)
{
await _unsub.DetectAsync(UserId, ct);
return NoContent();
}
/// <summary>Senders that are safe to unsubscribe from, ranked by volume.</summary>
[HttpGet("safe-list")]
public async Task<IActionResult> SafeList(CancellationToken ct)
=> Ok(await _unsub.GetSafeToUnsubscribeAsync(UserId, ct));
/// <summary>Process the confirmed unsubscribe queue. Requires Confirmed = true.</summary>
[HttpPost("process")]
public async Task<IActionResult> Process([FromBody] UnsubscribeRequestDto request, CancellationToken ct)
{
var result = await _unsub.ProcessQueueAsync(UserId, request, ct);
return result.Succeeded ? Ok(result.Value) : BadRequest(new { error = result.Error });
}
}
@@ -0,0 +1,53 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Entities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace InboxIntel.Api.Controllers;
public record WidgetLayoutDto(string WidgetKey, int X, int Y, int W, int H, bool Visible, int SortOrder, string? SettingsJson);
/// <summary>Persists the user's draggable/resizable dashboard layout.</summary>
public class WidgetLayoutController : ApiControllerBase
{
private readonly IAppDbContext _db;
public WidgetLayoutController(IAppDbContext db) => _db = db;
[HttpGet]
public async Task<IActionResult> Get(CancellationToken ct)
{
var layouts = await _db.WidgetLayouts
.Where(w => w.UserId == UserId)
.OrderBy(w => w.SortOrder)
.Select(w => new WidgetLayoutDto(w.WidgetKey, w.X, w.Y, w.W, w.H, w.Visible, w.SortOrder, w.SettingsJson))
.ToListAsync(ct);
return Ok(layouts);
}
/// <summary>Upserts the full layout for the user (replace semantics).</summary>
[HttpPut]
public async Task<IActionResult> Save([FromBody] List<WidgetLayoutDto> layout, CancellationToken ct)
{
var existing = await _db.WidgetLayouts.Where(w => w.UserId == UserId).ToListAsync(ct);
var byKey = existing.ToDictionary(w => w.WidgetKey);
foreach (var dto in layout)
{
if (byKey.TryGetValue(dto.WidgetKey, out var w))
{
w.X = dto.X; w.Y = dto.Y; w.W = dto.W; w.H = dto.H;
w.Visible = dto.Visible; w.SortOrder = dto.SortOrder; w.SettingsJson = dto.SettingsJson;
}
else
{
_db.WidgetLayouts.Add(new WidgetLayout
{
UserId = UserId, WidgetKey = dto.WidgetKey, X = dto.X, Y = dto.Y, W = dto.W, H = dto.H,
Visible = dto.Visible, SortOrder = dto.SortOrder, SettingsJson = dto.SettingsJson
});
}
}
await _db.SaveChangesAsync(ct);
return NoContent();
}
}
+20
View File
@@ -0,0 +1,20 @@
# Multi-stage build for the ASP.NET Core API.
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy solution + project files first for layer-cached restore.
COPY Directory.Build.props ./
COPY src/InboxIntel.Domain/InboxIntel.Domain.csproj src/InboxIntel.Domain/
COPY src/InboxIntel.Application/InboxIntel.Application.csproj src/InboxIntel.Application/
COPY src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj src/InboxIntel.Infrastructure/
COPY src/InboxIntel.Api/InboxIntel.Api.csproj src/InboxIntel.Api/
RUN dotnet restore src/InboxIntel.Api/InboxIntel.Api.csproj
COPY src/ src/
RUN dotnet publish src/InboxIntel.Api/InboxIntel.Api.csproj -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "InboxIntel.Api.dll"]
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>InboxIntel.Api</RootNamespace>
<AssemblyName>InboxIntel.Api</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\InboxIntel.Application\InboxIntel.Application.csproj" />
<ProjectReference Include="..\InboxIntel.Infrastructure\InboxIntel.Infrastructure.csproj" />
</ItemGroup>
</Project>
+100
View File
@@ -0,0 +1,100 @@
using System.Security.Claims;
using Asp.Versioning;
using InboxIntel.Api.Auth;
using InboxIntel.Application;
using InboxIntel.Application.Abstractions;
using InboxIntel.Infrastructure;
using InboxIntel.Infrastructure.Configuration;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Structured logging (Serilog). Note: token values are never logged.
builder.Host.UseSerilog((ctx, cfg) => cfg
.ReadFrom.Configuration(ctx.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console());
// Persist Data Protection keys so encrypted refresh tokens survive restarts.
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(builder.Configuration["DataProtection:KeyPath"] ?? "/keys"))
.SetApplicationName("InboxIntel");
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
// Authentication: cookie session established via Google OAuth2 (only login method).
var google = builder.Configuration.GetSection(GoogleOAuthOptions.SectionName).Get<GoogleOAuthOptions>() ?? new();
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromDays(7);
})
.AddGoogle(options =>
{
options.ClientId = google.ClientId;
options.ClientSecret = google.ClientSecret;
options.AccessType = "offline"; // request a refresh token
options.SaveTokens = true;
foreach (var scope in google.Scopes) options.Scope.Add(scope);
options.Events.OnCreatingTicket = GoogleAuthEvents.OnCreatingTicketAsync;
});
builder.Services.AddAuthorization();
builder.Services.AddApiVersioning(o =>
{
o.DefaultApiVersion = new ApiVersion(1, 0);
o.AssumeDefaultVersionWhenUnspecified = true;
o.ReportApiVersions = true;
o.ApiVersionReader = new UrlSegmentApiVersionReader();
}).AddApiExplorer(o => { o.GroupNameFormat = "'v'VVV"; o.SubstituteApiVersionInUrl = true; });
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddCors(o => o.AddPolicy("frontend", p => p
.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? new[] { "http://localhost:5173" })
.AllowAnyHeader().AllowAnyMethod().AllowCredentials()));
var app = builder.Build();
// Apply migrations on startup so `docker-compose up` yields a ready schema.
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
if (db.Database.IsRelational() && app.Configuration.GetValue("Database:AutoMigrate", true))
await db.Database.MigrateAsync();
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseSerilogRequestLogging();
app.UseCors("frontend");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
public partial class Program { }
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,10 @@
{
"DataProtection": {
"KeyPath": "./keys"
},
"Serilog": {
"MinimumLevel": {
"Default": "Debug"
}
}
}
+49
View File
@@ -0,0 +1,49 @@
{
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5432;Database=inboxintel;Username=inboxintel;Password=inboxintel"
},
"Database": {
"AutoMigrate": true
},
"DataProtection": {
"KeyPath": "/keys"
},
"GoogleOAuth": {
"ClientId": "",
"ClientSecret": "",
"Scopes": [
"openid",
"email",
"profile",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.modify"
]
},
"GmailSync": {
"PageSize": 100,
"MaxParallelism": 4,
"MaxRetries": 5,
"BackoffBaseMs": 500,
"DailySyncHourUtc": 3
},
"Ai": {
"Mode": "Disabled",
"OllamaBaseUrl": "http://localhost:11434",
"OllamaModel": "llama3.1",
"OpenAiApiKey": "",
"OpenAiModel": "gpt-4o-mini"
},
"Cors": {
"Origins": [ "http://localhost:5173" ]
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
}
},
"AllowedHosts": "*"
}