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
@@ -0,0 +1,26 @@
using InboxIntel.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace InboxIntel.Application.Abstractions;
/// <summary>
/// Abstraction over the EF Core DbContext so the Application layer can query
/// without depending on Infrastructure. Implemented by AppDbContext.
/// </summary>
public interface IAppDbContext
{
DbSet<User> Users { get; }
DbSet<Email> Emails { get; }
DbSet<MailThread> Threads { get; }
DbSet<Sender> Senders { get; }
DbSet<MailDomain> Domains { get; }
DbSet<Attachment> Attachments { get; }
DbSet<Label> Labels { get; }
DbSet<EmailLabel> EmailLabels { get; }
DbSet<SyncState> SyncStates { get; }
DbSet<AnalyticsAggregate> AnalyticsAggregates { get; }
DbSet<WidgetLayout> WidgetLayouts { get; }
DbSet<UnsubscribeItem> UnsubscribeItems { get; }
Task<int> SaveChangesAsync(CancellationToken ct = default);
}
@@ -0,0 +1,18 @@
namespace InboxIntel.Application.Abstractions;
/// <summary>Resolves the authenticated user for the current request/scope.</summary>
public interface ICurrentUser
{
Guid UserId { get; }
bool IsAuthenticated { get; }
}
/// <summary>
/// Protects secrets (OAuth refresh tokens) at rest. Implemented with the
/// ASP.NET Core Data Protection API (AES). Tokens are never logged.
/// </summary>
public interface ITokenProtector
{
byte[] Protect(string plaintext);
string Unprotect(byte[] ciphertext);
}
@@ -0,0 +1,51 @@
using InboxIntel.Domain.Entities;
namespace InboxIntel.Application.Abstractions;
/// <summary>
/// Thin wrapper over the Gmail REST API. Implementations must respect quotas,
/// apply retry with exponential backoff, and validate every response.
/// </summary>
public interface IGmailService
{
Task<string> GetProfileHistoryIdAsync(Guid userId, CancellationToken ct = default);
/// <summary>Lists message ids, page by page, for a full sync.</summary>
Task<GmailMessagePage> ListMessageIdsAsync(Guid userId, string? pageToken, CancellationToken ct = default);
/// <summary>Fetches and parses a single message into a domain Email graph.</summary>
Task<GmailMessageDetail> GetMessageAsync(Guid userId, string gmailMessageId, CancellationToken ct = default);
/// <summary>Delta changes since a historyId, for incremental sync.</summary>
Task<GmailHistoryPage> ListHistoryAsync(Guid userId, string startHistoryId, string? pageToken, CancellationToken ct = default);
Task<IReadOnlyList<Label>> ListLabelsAsync(Guid userId, CancellationToken ct = default);
// Mutations used by the cleanup system.
Task BatchModifyAsync(Guid userId, IEnumerable<string> messageIds, IEnumerable<string> addLabelIds, IEnumerable<string> removeLabelIds, CancellationToken ct = default);
Task BatchTrashAsync(Guid userId, IEnumerable<string> messageIds, CancellationToken ct = default);
Task BatchDeleteAsync(Guid userId, IEnumerable<string> messageIds, CancellationToken ct = default);
}
public record GmailMessagePage(IReadOnlyList<string> MessageIds, string? NextPageToken, int ResultSizeEstimate);
public record GmailHistoryPage(IReadOnlyList<string> ChangedMessageIds, IReadOnlyList<string> DeletedMessageIds, string? NextPageToken, string? NewHistoryId);
/// <summary>Parsed message plus the extracted unsubscribe signals.</summary>
public record GmailMessageDetail(
string GmailMessageId,
string GmailThreadId,
string FromAddress,
string? FromDisplayName,
string? Subject,
string? Snippet,
string? BodyText,
DateTimeOffset SentAtUtc,
long SizeEstimateBytes,
bool IsUnread,
bool HasAttachments,
IReadOnlyList<string> LabelIds,
IReadOnlyList<(string FileName, string? MimeType, long Size, string? AttachmentId)> Attachments,
bool HasListUnsubscribe,
string? ListUnsubscribeRaw,
bool SupportsOneClickUnsubscribe);
@@ -0,0 +1,69 @@
using InboxIntel.Application.Common;
using InboxIntel.Application.DTOs;
using InboxIntel.Domain.Enums;
namespace InboxIntel.Application.Abstractions;
/// <summary>Orchestrates full + incremental Gmail sync, with resume support.</summary>
public interface ISyncService
{
Task RunFullSyncAsync(Guid userId, CancellationToken ct = default);
Task RunIncrementalSyncAsync(Guid userId, CancellationToken ct = default);
Task<SyncStatus> GetStatusAsync(Guid userId, CancellationToken ct = default);
}
public interface IAnalyticsService
{
Task<DashboardSummaryDto> GetDashboardAsync(Guid userId, CancellationToken ct = default);
Task<InboxHealthDto> GetInboxHealthAsync(Guid userId, CancellationToken ct = default);
Task<IReadOnlyList<SenderStatDto>> GetTopSendersAsync(Guid userId, int take = 20, CancellationToken ct = default);
Task<IReadOnlyList<TimeSeriesPointDto>> GetVolumeOverTimeAsync(Guid userId, int days = 90, CancellationToken ct = default);
Task<IReadOnlyList<HeatmapCellDto>> GetHeatmapAsync(Guid userId, CancellationToken ct = default);
Task<IReadOnlyList<AttachmentBreakdownDto>> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default);
Task RefreshAggregatesAsync(Guid userId, CancellationToken ct = default);
}
public interface ISearchService
{
Task<PagedResult<EmailSummaryDto>> SearchAsync(Guid userId, SearchRequestDto request, CancellationToken ct = default);
}
public interface ICleanupService
{
Task<CleanupPreviewDto> PreviewAsync(Guid userId, CleanupRequestDto request, CancellationToken ct = default);
Task<Result<CleanupResultDto>> ExecuteAsync(Guid userId, CleanupRequestDto request, CancellationToken ct = default);
}
public interface IUnsubscribeService
{
Task<IReadOnlyList<UnsubscribeItemDto>> GetSafeToUnsubscribeAsync(Guid userId, CancellationToken ct = default);
Task DetectAsync(Guid userId, CancellationToken ct = default);
Task<Result<CleanupResultDto>> ProcessQueueAsync(Guid userId, UnsubscribeRequestDto request, CancellationToken ct = default);
}
/// <summary>
/// AI layer. May be disabled, local (Ollama) or cloud (OpenAI). Per the
/// safety rules, AI NEVER executes destructive actions - it only suggests.
/// </summary>
public interface IAiService
{
bool IsEnabled { get; }
Task<AiClassificationDto> ClassifyAsync(Guid userId, Guid emailId, CancellationToken ct = default);
Task<InboxSummaryDto> SummarizeInboxAsync(Guid userId, CancellationToken ct = default);
Task<IReadOnlyList<AiCleanupSuggestionDto>> SuggestCleanupAsync(Guid userId, CancellationToken ct = default);
Task<GeneratedQueryDto> GenerateQueryAsync(Guid userId, string naturalLanguage, CancellationToken ct = default);
}
/// <summary>Low-level chat completion abstraction implemented by Ollama / OpenAI providers.</summary>
public interface IAiProvider
{
AiProviderMode Mode { get; }
Task<string> CompleteAsync(string systemPrompt, string userPrompt, CancellationToken ct = default);
}
public enum ExportFormat { Pdf, Csv, Json }
public interface IExportService
{
Task<(byte[] Content, string ContentType, string FileName)> ExportReportAsync(Guid userId, ExportFormat format, CancellationToken ct = default);
}
@@ -0,0 +1,29 @@
namespace InboxIntel.Application.Common;
/// <summary>Lightweight result wrapper to avoid throwing for expected failures.</summary>
public class Result
{
public bool Succeeded { get; init; }
public string? Error { get; init; }
public static Result Success() => new() { Succeeded = true };
public static Result Failure(string error) => new() { Succeeded = false, Error = error };
}
public class Result<T> : Result
{
public T? Value { get; init; }
public static Result<T> Success(T value) => new() { Succeeded = true, Value = value };
public static new Result<T> Failure(string error) => new() { Succeeded = false, Error = error };
}
/// <summary>Standard paged response used by list endpoints.</summary>
public class PagedResult<T>
{
public IReadOnlyList<T> Items { get; init; } = Array.Empty<T>();
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalCount { get; init; }
public int TotalPages => PageSize == 0 ? 0 : (int)Math.Ceiling(TotalCount / (double)PageSize);
}
+17
View File
@@ -0,0 +1,17 @@
using InboxIntel.Domain.Enums;
namespace InboxIntel.Application.DTOs;
public record AiClassificationDto(Guid EmailId, EmailCategory Category, double Confidence);
public record InboxSummaryDto(string Summary, IReadOnlyList<string> Highlights);
public record AiCleanupSuggestionDto(
string Title,
string Rationale,
CleanupActionType SuggestedAction,
string? Query,
int EstimatedAffected);
/// <summary>Natural language -> Gmail-like query suggestion.</summary>
public record GeneratedQueryDto(string NaturalLanguage, string GmailQuery);
@@ -0,0 +1,27 @@
namespace InboxIntel.Application.DTOs;
public record InboxHealthDto(
int Score, // 0-100
string Grade, // A-F
int TotalEmails,
int UnreadEmails,
int NewsletterCount,
int SafeToUnsubscribeCount,
long EstimatedStorageBytes,
IReadOnlyList<string> Recommendations);
public record TimeSeriesPointDto(DateOnly Day, int Count);
public record HeatmapCellDto(int DayOfWeek, int Hour, int Count);
public record AttachmentBreakdownDto(string MimeBucket, long TotalBytes, int Count);
public record DashboardSummaryDto(
InboxHealthDto Health,
int TotalEmails,
int UnreadEmails,
IReadOnlyList<SenderStatDto> TopSenders,
IReadOnlyList<TimeSeriesPointDto> VolumeOverTime,
IReadOnlyList<HeatmapCellDto> Heatmap,
IReadOnlyList<AttachmentBreakdownDto> AttachmentBreakdown,
long StorageEstimateBytes);
@@ -0,0 +1,38 @@
using InboxIntel.Domain.Enums;
namespace InboxIntel.Application.DTOs;
/// <summary>Request to run a bulk cleanup action over a set of emails or a query.</summary>
public record CleanupRequestDto(
CleanupActionType Action,
IReadOnlyList<Guid>? EmailIds,
string? Query,
string? LabelId,
bool Confirmed);
/// <summary>
/// Preview of what a cleanup action would affect. Per the safety rules, no
/// destructive action runs until the user confirms this preview.
/// </summary>
public record CleanupPreviewDto(
CleanupActionType Action,
int AffectedCount,
long AffectedSizeBytes,
IReadOnlyList<EmailSummaryDto> Sample);
public record CleanupResultDto(
CleanupActionType Action,
int SucceededCount,
int FailedCount,
IReadOnlyList<string> Errors);
public record UnsubscribeItemDto(
Guid Id,
string SenderAddress,
string Domain,
UnsubscribeMethod Method,
UnsubscribeStatus Status,
int EmailCount,
string? UnsubscribeTarget);
public record UnsubscribeRequestDto(IReadOnlyList<Guid> ItemIds, bool Confirmed);
@@ -0,0 +1,33 @@
using InboxIntel.Domain.Enums;
namespace InboxIntel.Application.DTOs;
public record EmailSummaryDto(
Guid Id,
string GmailMessageId,
string? Subject,
string? Snippet,
string SenderAddress,
string? SenderDisplayName,
DateTimeOffset SentAtUtc,
bool IsUnread,
bool HasAttachments,
long SizeEstimateBytes,
EmailCategory Category);
public record SenderStatDto(
Guid SenderId,
string Address,
string? DisplayName,
string Domain,
int EmailCount,
int UnreadCount,
long TotalSizeBytes,
bool HasUnsubscribe,
DateTimeOffset? LastReceivedUtc);
public record AttachmentDto(
Guid Id,
string FileName,
string? MimeType,
long SizeBytes);
@@ -0,0 +1,19 @@
namespace InboxIntel.Application.DTOs;
/// <summary>
/// Advanced search request. <see cref="Query"/> accepts Gmail-like syntax
/// (e.g. "from:github.com is:unread has:attachment newsletter") which the
/// query parser decomposes into structured filters; remaining free text runs
/// through PostgreSQL full-text search.
/// </summary>
public record SearchRequestDto(
string? Query,
string? Sender,
string? Domain,
DateOnly? From,
DateOnly? To,
bool? IsUnread,
bool? HasAttachments,
bool FuzzyMatch = false,
int Page = 1,
int PageSize = 50);
@@ -0,0 +1,14 @@
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
namespace InboxIntel.Application;
public static class DependencyInjection
{
/// <summary>Registers Application-layer services (validators, etc.).</summary>
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly);
return services;
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>InboxIntel.Application</RootNamespace>
<AssemblyName>InboxIntel.Application</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.9.2" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<!-- DbSet<> is exposed on IAppDbContext so the Application layer can query.
Pinned to 8.0.4 to match the Npgsql provider's Relational dependency. -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,51 @@
using System.Text.RegularExpressions;
using InboxIntel.Application.DTOs;
namespace InboxIntel.Application.Search;
/// <summary>
/// Parses Gmail-like query strings into a structured <see cref="SearchRequestDto"/>.
/// Supports operators: from:, to:, domain:, after:, before:, is:unread,
/// is:read, has:attachment. Any unmatched text becomes the free-text query.
/// </summary>
public static class GmailQueryParser
{
private static readonly Regex TokenRegex = new(
@"(?<key>from|to|domain|after|before|is|has):(?<val>""[^""]+""|\S+)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static SearchRequestDto Parse(string? raw, int page = 1, int pageSize = 50, bool fuzzy = false)
{
if (string.IsNullOrWhiteSpace(raw))
return new SearchRequestDto(null, null, null, null, null, null, null, fuzzy, page, pageSize);
string? sender = null, domain = null;
DateOnly? from = null, to = null;
bool? isUnread = null, hasAttachments = null;
var freeText = TokenRegex.Replace(raw, match =>
{
var key = match.Groups["key"].Value.ToLowerInvariant();
var val = match.Groups["val"].Value.Trim('"');
switch (key)
{
case "from": sender = val; break;
case "domain": domain = val; break;
case "after": if (DateOnly.TryParse(val, out var a)) from = a; break;
case "before": if (DateOnly.TryParse(val, out var b)) to = b; break;
case "is":
if (val.Equals("unread", StringComparison.OrdinalIgnoreCase)) isUnread = true;
else if (val.Equals("read", StringComparison.OrdinalIgnoreCase)) isUnread = false;
break;
case "has":
if (val.Equals("attachment", StringComparison.OrdinalIgnoreCase)) hasAttachments = true;
break;
}
return string.Empty;
}).Trim();
return new SearchRequestDto(
string.IsNullOrWhiteSpace(freeText) ? null : freeText,
sender, domain, from, to, isUnread, hasAttachments, fuzzy, page, pageSize);
}
}
@@ -0,0 +1,48 @@
using FluentValidation;
using InboxIntel.Application.DTOs;
using InboxIntel.Domain.Enums;
namespace InboxIntel.Application.Validation;
public class SearchRequestValidator : AbstractValidator<SearchRequestDto>
{
public SearchRequestValidator()
{
RuleFor(x => x.Page).GreaterThan(0);
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
RuleFor(x => x)
.Must(x => x.From is null || x.To is null || x.From <= x.To)
.WithMessage("'From' date must be on or before 'To' date.");
}
}
public class CleanupRequestValidator : AbstractValidator<CleanupRequestDto>
{
public CleanupRequestValidator()
{
RuleFor(x => x)
.Must(x => (x.EmailIds is { Count: > 0 }) || !string.IsNullOrWhiteSpace(x.Query))
.WithMessage("Provide either EmailIds or a Query to target emails.");
// Safety rule: destructive actions must be explicitly confirmed.
RuleFor(x => x.Confirmed)
.Equal(true)
.When(x => x.Action is CleanupActionType.Trash or CleanupActionType.HardDelete)
.WithMessage("Destructive actions require explicit confirmation.");
RuleFor(x => x.LabelId)
.NotEmpty()
.When(x => x.Action is CleanupActionType.AddLabel or CleanupActionType.RemoveLabel)
.WithMessage("A LabelId is required for label actions.");
}
}
public class UnsubscribeRequestValidator : AbstractValidator<UnsubscribeRequestDto>
{
public UnsubscribeRequestValidator()
{
RuleFor(x => x.ItemIds).NotEmpty();
RuleFor(x => x.Confirmed).Equal(true)
.WithMessage("Unsubscribe actions require explicit confirmation.");
}
}