chore: init project
This commit is contained in:
@@ -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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user