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,25 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// Pre-computed daily rollup so dashboard widgets render instantly without
/// scanning the full email table. Refreshed by the analytics worker.
/// </summary>
public class AnalyticsAggregate : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
/// <summary>Calendar day (UTC, date-only) this row aggregates.</summary>
public DateOnly Day { get; set; }
public int TotalReceived { get; set; }
public int TotalUnread { get; set; }
public int NewsletterCount { get; set; }
public int WithAttachments { get; set; }
public long TotalSizeBytes { get; set; }
/// <summary>JSON: hour-of-day -> count, backing the heatmap widget.</summary>
public string? HourHistogramJson { get; set; }
}
@@ -0,0 +1,23 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// Attachment metadata only - the binary content is never downloaded or stored.
/// Powers the "attachment size breakdown" widget and storage estimates.
/// </summary>
public class Attachment : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public Guid EmailId { get; set; }
public Email? Email { get; set; }
/// <summary>Gmail attachment id (for on-demand download if ever needed).</summary>
public string? GmailAttachmentId { get; set; }
public string FileName { get; set; } = string.Empty;
public string? MimeType { get; set; }
public long SizeBytes { get; set; }
}
+59
View File
@@ -0,0 +1,59 @@
using InboxIntel.Domain.Common;
using InboxIntel.Domain.Enums;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// A single Gmail message stored locally. Designed for 100k+ rows per user:
/// search fields are indexed (see EmailConfiguration) and a generated
/// tsvector column backs PostgreSQL full-text search.
/// </summary>
public class Email : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
/// <summary>Gmail message id (stable, unique per account).</summary>
public string GmailMessageId { get; set; } = string.Empty;
public Guid ThreadId { get; set; }
public MailThread? Thread { get; set; }
public Guid SenderId { get; set; }
public Sender? Sender { get; set; }
public string? Subject { get; set; }
public string? Snippet { get; set; }
/// <summary>Plain-text body. Indexed for FTS via SearchVector.</summary>
public string? BodyText { get; set; }
public DateTimeOffset SentAtUtc { get; set; }
public DateTimeOffset? ReceivedAtUtc { get; set; }
public long SizeEstimateBytes { get; set; }
public bool IsUnread { get; set; }
public bool IsStarred { get; set; }
public bool IsImportant { get; set; }
public bool IsInInbox { get; set; }
public bool IsTrashed { get; set; }
public bool HasAttachments { get; set; }
// Unsubscribe signals captured at parse time.
public bool HasListUnsubscribe { get; set; }
public string? ListUnsubscribeRaw { get; set; }
public bool SupportsOneClickUnsubscribe { get; set; }
/// <summary>Heuristic/AI classification. Defaults to Unknown until classified.</summary>
public EmailCategory Category { get; set; } = EmailCategory.Unknown;
/// <summary>
/// PostgreSQL tsvector, maintained as a generated column. Never set in code;
/// mapped read-only for querying. Nullable so non-Postgres test providers work.
/// </summary>
public NpgsqlTypes.NpgsqlTsVector? SearchVector { get; set; }
public ICollection<EmailLabel> EmailLabels { get; set; } = new List<EmailLabel>();
public ICollection<Attachment> Attachments { get; set; } = new List<Attachment>();
}
+32
View File
@@ -0,0 +1,32 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>A Gmail label (system or user-defined), mapped locally.</summary>
public class Label : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
/// <summary>Gmail label id, e.g. "INBOX", "Label_42".</summary>
public string GmailLabelId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
/// <summary>"system" or "user".</summary>
public string Type { get; set; } = "user";
public string? ColorHex { get; set; }
public ICollection<EmailLabel> EmailLabels { get; set; } = new List<EmailLabel>();
}
/// <summary>Join entity for the many-to-many Email &lt;-&gt; Label relationship.</summary>
public class EmailLabel
{
public Guid EmailId { get; set; }
public Email? Email { get; set; }
public Guid LabelId { get; set; }
public Label? Label { get; set; }
}
@@ -0,0 +1,22 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// A sending domain (e.g. "github.com"), extracted from sender addresses.
/// Aggregating at the domain level powers fast grouping and the
/// safe-to-unsubscribe list.
/// </summary>
public class MailDomain : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
/// <summary>Lower-cased registrable domain, e.g. "news.github.com".</summary>
public string Name { get; set; } = string.Empty;
public int EmailCount { get; set; }
public bool IsBulkSender { get; set; }
public ICollection<Sender> Senders { get; set; } = new List<Sender>();
}
@@ -0,0 +1,23 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// A Gmail conversation thread, reconstructed from the Gmail threadId.
/// </summary>
public class MailThread : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
/// <summary>Gmail-assigned thread id.</summary>
public string GmailThreadId { get; set; } = string.Empty;
public string? Subject { get; set; }
public string? Snippet { get; set; }
public int MessageCount { get; set; }
public DateTimeOffset? FirstMessageUtc { get; set; }
public DateTimeOffset? LastMessageUtc { get; set; }
public ICollection<Email> Emails { get; set; } = new List<Email>();
}
+30
View File
@@ -0,0 +1,30 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// A distinct sender address (e.g. "noreply@github.com"). Cleanup and
/// unsubscribe operations are most often grouped by sender.
/// </summary>
public class Sender : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public Guid DomainId { get; set; }
public MailDomain? Domain { get; set; }
/// <summary>Lower-cased full address.</summary>
public string Address { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public int EmailCount { get; set; }
public int UnreadCount { get; set; }
public long TotalSizeBytes { get; set; }
public DateTimeOffset? LastReceivedUtc { get; set; }
/// <summary>True if any message from this sender carried a List-Unsubscribe header.</summary>
public bool HasUnsubscribe { get; set; }
public ICollection<Email> Emails { get; set; } = new List<Email>();
}
@@ -0,0 +1,33 @@
using InboxIntel.Domain.Common;
using InboxIntel.Domain.Enums;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// Tracks Gmail sync progress so a run can resume after failure and so
/// incremental (delta) syncs know where to continue from.
/// </summary>
public class SyncState : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public SyncStatus Status { get; set; } = SyncStatus.Idle;
public SyncType LastSyncType { get; set; } = SyncType.Full;
/// <summary>Gmail historyId watermark for incremental delta sync.</summary>
public string? LastHistoryId { get; set; }
/// <summary>Opaque pageToken to resume an interrupted full sync.</summary>
public string? ResumePageToken { get; set; }
public int TotalMessagesEstimate { get; set; }
public int MessagesProcessed { get; set; }
public DateTimeOffset? StartedUtc { get; set; }
public DateTimeOffset? CompletedUtc { get; set; }
public DateTimeOffset? LastSuccessfulSyncUtc { get; set; }
public int ConsecutiveFailures { get; set; }
public string? LastError { get; set; }
}
@@ -0,0 +1,29 @@
using InboxIntel.Domain.Common;
using InboxIntel.Domain.Enums;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// A detected unsubscribe opportunity, grouped per sender/domain. Items move
/// through a queue; actions only run after explicit user confirmation.
/// </summary>
public class UnsubscribeItem : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public Guid SenderId { get; set; }
public Sender? Sender { get; set; }
public UnsubscribeMethod Method { get; set; } = UnsubscribeMethod.None;
public UnsubscribeStatus Status { get; set; } = UnsubscribeStatus.Detected;
/// <summary>http(s) unsubscribe URL or mailto target extracted from header/body.</summary>
public string? UnsubscribeTarget { get; set; }
/// <summary>Number of emails from this sender (drives "safe to unsubscribe" ranking).</summary>
public int EmailCount { get; set; }
public DateTimeOffset? LastAttemptUtc { get; set; }
public string? ResultMessage { get; set; }
}
+31
View File
@@ -0,0 +1,31 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// A user authenticated via Google OAuth2. The system is single-user in the
/// initial version, but the schema is multi-user ready (every owned entity
/// carries a UserId foreign key).
/// </summary>
public class User : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>Google "sub" claim - stable unique identifier for the Google account.</summary>
public string GoogleSubjectId { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public string? PictureUrl { get; set; }
/// <summary>OAuth2 refresh token, AES-encrypted at rest. Never logged.</summary>
public byte[]? EncryptedRefreshToken { get; set; }
/// <summary>Most recent access token expiry, used to decide when to refresh.</summary>
public DateTimeOffset? AccessTokenExpiresAtUtc { get; set; }
public DateTimeOffset? LastLoginUtc { get; set; }
public ICollection<Email> Emails { get; set; } = new List<Email>();
public ICollection<WidgetLayout> WidgetLayouts { get; set; } = new List<WidgetLayout>();
}
@@ -0,0 +1,28 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// Persisted dashboard layout for a user. One row per widget instance,
/// storing grid geometry and visibility so the dashboard restores exactly.
/// </summary>
public class WidgetLayout : AuditableEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
/// <summary>Stable widget key, e.g. "inbox-health", "top-senders".</summary>
public string WidgetKey { get; set; } = string.Empty;
// react-grid-layout geometry.
public int X { get; set; }
public int Y { get; set; }
public int W { get; set; } = 4;
public int H { get; set; } = 4;
public bool Visible { get; set; } = true;
public int SortOrder { get; set; }
/// <summary>Optional per-widget settings as JSON.</summary>
public string? SettingsJson { get; set; }
}