diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 915e771..f8df3ff 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -94,13 +94,22 @@ function folderToRequest(slug, page, pageSize) { case 'starred': return { ...base, isStarred: true }; case 'sent': return { ...base, gmailLabel: 'SENT' }; case 'drafts': return { ...base, gmailLabel: 'DRAFT' }; - case 'archive': return { ...base, isInInbox: false, isTrashed: false }; + // Archive = filed away: not in inbox, not trashed, and NOT Sent/Spam/Draft/Chat. + // Excluding those labels stops sent mail leaking into Archive. + case 'archive': return { ...base, isInInbox: false, isTrashed: false, excludeGmailLabels: ['SENT', 'DRAFT', 'SPAM', 'TRASH', 'CHAT'] }; case 'spam': return { ...base, gmailLabel: 'SPAM' }; case 'trash': return { ...base, isTrashed: true }; + // Pinned = Gmail's "Important" marker (a real per-message flag), not "everything". + case 'pinned': return { ...base, isImportant: true }; + // Read Later = only emails the user explicitly flagged (local marker). + case 'readlater': return { ...base, isReadLater: true }; + // Unlabelled = emails not filed under any user-created label. + case 'unlabelled': return { ...base, hasUserLabels: false }; // ── Special filters ── case 'large': return { ...base, minSizeBytes: 5_000_000 }; + // Old Mail = strictly older than 6 months. case 'old': { - const d = new Date(); d.setFullYear(d.getFullYear() - 1); + const d = new Date(); d.setMonth(d.getMonth() - 6); return { ...base, to: d.toISOString().slice(0, 10) }; } // ── Smart folders ── diff --git a/src/InboxIntel.Api/Controllers/EmailController.cs b/src/InboxIntel.Api/Controllers/EmailController.cs index 94d52e2..3d43702 100644 --- a/src/InboxIntel.Api/Controllers/EmailController.cs +++ b/src/InboxIntel.Api/Controllers/EmailController.cs @@ -72,6 +72,22 @@ public class EmailController : ApiControllerBase [HttpPost("{id:guid}/untrash")] public Task Untrash(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Archive, ct); + /// Toggle the local-only Read Later marker (no Gmail side effect). + [HttpPost("{id:guid}/readlater")] + public Task ReadLater(Guid id, CancellationToken ct) => SetReadLater(id, true, ct); + + [HttpPost("{id:guid}/unreadlater")] + public Task UnreadLater(Guid id, CancellationToken ct) => SetReadLater(id, false, ct); + + private async Task SetReadLater(Guid id, bool value, CancellationToken ct) + { + var email = await _db.Emails.FirstOrDefaultAsync(e => e.Id == id && e.UserId == UserId, ct); + if (email is null) return NotFound(); + email.IsReadLater = value; + await _db.SaveChangesAsync(ct); + return Ok(); + } + /// /// Inline unsubscribe. Detects the unsubscribe mechanism for the email's sender, /// then executes it (HTTP one-click or HTTP link). mailto targets cannot be sent diff --git a/src/InboxIntel.Application/DTOs/SearchDtos.cs b/src/InboxIntel.Application/DTOs/SearchDtos.cs index e9322a1..04b456f 100644 --- a/src/InboxIntel.Application/DTOs/SearchDtos.cs +++ b/src/InboxIntel.Application/DTOs/SearchDtos.cs @@ -21,7 +21,15 @@ public record SearchRequestDto( bool? IsInInbox = null, bool? IsStarred = null, bool? IsTrashed = null, + bool? IsImportant = null, // Gmail "important" marker — backs the Pinned smart folder + bool? IsReadLater = null, // local Read Later marker string? GmailLabel = null, // e.g. "SENT", "DRAFT", "SPAM" + // Emails carrying NONE of these Gmail labels (by GmailLabelId). Backs Archive, which + // must exclude Sent/Spam/Draft/Chat/Trash rather than just "not in inbox". + IReadOnlyList? ExcludeGmailLabels = null, + // true = only emails with at least one USER label; false = only emails with NO user + // labels (the Unlabelled folder). System labels (INBOX/SENT/…) don't count. + bool? HasUserLabels = null, string? Category = null, // EmailCategory name, e.g. "Finance" long? MinSizeBytes = null, // Keyset cursor for the date-ordered browse path (RECOMMENDATIONS #8): pass the last diff --git a/src/InboxIntel.Domain/Entities/Email.cs b/src/InboxIntel.Domain/Entities/Email.cs index 3282efc..9e362be 100644 --- a/src/InboxIntel.Domain/Entities/Email.cs +++ b/src/InboxIntel.Domain/Entities/Email.cs @@ -40,6 +40,10 @@ public class Email : AuditableEntity public bool IsTrashed { get; set; } public bool HasAttachments { get; set; } + /// Local "Read Later" marker (not a Gmail concept) — toggled by the user so the + /// Read Later smart folder shows only explicitly flagged mail, never everything. + public bool IsReadLater { get; set; } + // Unsubscribe signals captured at parse time. public bool HasListUnsubscribe { get; set; } public string? ListUnsubscribeRaw { get; set; } diff --git a/src/InboxIntel.Infrastructure/Gmail/GmailService.cs b/src/InboxIntel.Infrastructure/Gmail/GmailService.cs index 0aa0b50..2bffb21 100644 --- a/src/InboxIntel.Infrastructure/Gmail/GmailService.cs +++ b/src/InboxIntel.Infrastructure/Gmail/GmailService.cs @@ -95,7 +95,9 @@ public class GmailApiService : IGmailService var req = client.Users.Messages.List("me"); req.MaxResults = _options.PageSize; req.PageToken = pageToken; - req.IncludeSpamTrash = false; + // Include spam & trash so those folders aren't structurally empty; their state is + // captured via the SPAM/TRASH labels (IsTrashed + EmailLabels) during upsert. + req.IncludeSpamTrash = true; return await req.ExecuteAsync(token); }, ct); diff --git a/src/InboxIntel.Infrastructure/Migrations/20260630140733_InitialCreate.Designer.cs b/src/InboxIntel.Infrastructure/Migrations/20260630140733_InitialCreate.Designer.cs index fa742ee..f156b82 100644 --- a/src/InboxIntel.Infrastructure/Migrations/20260630140733_InitialCreate.Designer.cs +++ b/src/InboxIntel.Infrastructure/Migrations/20260630140733_InitialCreate.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using InboxIntel.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; diff --git a/src/InboxIntel.Infrastructure/Migrations/20260630200946_AddUserDigestFields.Designer.cs b/src/InboxIntel.Infrastructure/Migrations/20260630200946_AddUserDigestFields.Designer.cs index 1a812aa..364d091 100644 --- a/src/InboxIntel.Infrastructure/Migrations/20260630200946_AddUserDigestFields.Designer.cs +++ b/src/InboxIntel.Infrastructure/Migrations/20260630200946_AddUserDigestFields.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using InboxIntel.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; diff --git a/src/InboxIntel.Infrastructure/Migrations/20260630201607_AddUnsubscribeConfidence.Designer.cs b/src/InboxIntel.Infrastructure/Migrations/20260630201607_AddUnsubscribeConfidence.Designer.cs index 036a720..884d818 100644 --- a/src/InboxIntel.Infrastructure/Migrations/20260630201607_AddUnsubscribeConfidence.Designer.cs +++ b/src/InboxIntel.Infrastructure/Migrations/20260630201607_AddUnsubscribeConfidence.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using InboxIntel.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; diff --git a/src/InboxIntel.Infrastructure/Migrations/20260701192100_WeightSearchVectorSubjectBody.Designer.cs b/src/InboxIntel.Infrastructure/Migrations/20260701192100_WeightSearchVectorSubjectBody.Designer.cs index 34a37a1..c385932 100644 --- a/src/InboxIntel.Infrastructure/Migrations/20260701192100_WeightSearchVectorSubjectBody.Designer.cs +++ b/src/InboxIntel.Infrastructure/Migrations/20260701192100_WeightSearchVectorSubjectBody.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using InboxIntel.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; diff --git a/src/InboxIntel.Infrastructure/Migrations/20260701203001_EnablePgTrgm.Designer.cs b/src/InboxIntel.Infrastructure/Migrations/20260701203001_EnablePgTrgm.Designer.cs index 846cc5e..b1a611b 100644 --- a/src/InboxIntel.Infrastructure/Migrations/20260701203001_EnablePgTrgm.Designer.cs +++ b/src/InboxIntel.Infrastructure/Migrations/20260701203001_EnablePgTrgm.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using InboxIntel.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; diff --git a/src/InboxIntel.Infrastructure/Migrations/20260701235112_TrigramIndexesSenderDomain.Designer.cs b/src/InboxIntel.Infrastructure/Migrations/20260701235112_TrigramIndexesSenderDomain.Designer.cs index 81e5395..57989e9 100644 --- a/src/InboxIntel.Infrastructure/Migrations/20260701235112_TrigramIndexesSenderDomain.Designer.cs +++ b/src/InboxIntel.Infrastructure/Migrations/20260701235112_TrigramIndexesSenderDomain.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using InboxIntel.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; diff --git a/src/InboxIntel.Infrastructure/Migrations/20260702002304_AddEmbeddingColumn.Designer.cs b/src/InboxIntel.Infrastructure/Migrations/20260702002304_AddEmbeddingColumn.Designer.cs index 335c335..6ffacee 100644 --- a/src/InboxIntel.Infrastructure/Migrations/20260702002304_AddEmbeddingColumn.Designer.cs +++ b/src/InboxIntel.Infrastructure/Migrations/20260702002304_AddEmbeddingColumn.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using InboxIntel.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; diff --git a/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.Designer.cs b/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.Designer.cs index a2d7e12..12ff438 100644 --- a/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.Designer.cs +++ b/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using InboxIntel.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; diff --git a/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.cs b/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.cs index 013fee7..4c5f040 100644 --- a/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.cs +++ b/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/InboxIntel.Infrastructure/Migrations/20260704141744_AddIsReadLater.Designer.cs b/src/InboxIntel.Infrastructure/Migrations/20260704141744_AddIsReadLater.Designer.cs new file mode 100644 index 0000000..7982a8b --- /dev/null +++ b/src/InboxIntel.Infrastructure/Migrations/20260704141744_AddIsReadLater.Designer.cs @@ -0,0 +1,834 @@ +// +using System; +using InboxIntel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; +using Pgvector; + +#nullable disable + +namespace InboxIntel.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260704141744_AddIsReadLater")] + partial class AddIsReadLater + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("InboxIntel.Domain.Entities.AnalyticsAggregate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Day") + .HasColumnType("date"); + + b.Property("HourHistogramJson") + .HasColumnType("text"); + + b.Property("NewsletterCount") + .HasColumnType("integer"); + + b.Property("TotalReceived") + .HasColumnType("integer"); + + b.Property("TotalSizeBytes") + .HasColumnType("bigint"); + + b.Property("TotalUnread") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("WithAttachments") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Day") + .IsUnique(); + + b.ToTable("analytics_aggregates", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("GmailAttachmentId") + .HasColumnType("text"); + + b.Property("MimeType") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EmailId"); + + b.HasIndex("UserId", "MimeType"); + + b.ToTable("attachments", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BodyText") + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Embedding") + .HasColumnType("vector(768)"); + + b.Property("GmailMessageId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HasAttachments") + .HasColumnType("boolean"); + + b.Property("HasListUnsubscribe") + .HasColumnType("boolean"); + + b.Property("IsImportant") + .HasColumnType("boolean"); + + b.Property("IsInInbox") + .HasColumnType("boolean"); + + b.Property("IsReadLater") + .HasColumnType("boolean"); + + b.Property("IsStarred") + .HasColumnType("boolean"); + + b.Property("IsTrashed") + .HasColumnType("boolean"); + + b.Property("IsUnread") + .HasColumnType("boolean"); + + b.Property("ListUnsubscribeRaw") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SearchVector") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SizeEstimateBytes") + .HasColumnType("bigint"); + + b.Property("Snippet") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Subject") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("SupportsOneClickUnsubscribe") + .HasColumnType("boolean"); + + b.Property("ThreadId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Embedding"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" }); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN"); + + b.HasIndex("SenderId"); + + b.HasIndex("ThreadId"); + + b.HasIndex("UserId", "Category"); + + b.HasIndex("UserId", "GmailMessageId") + .IsUnique(); + + b.HasIndex("UserId", "IsInInbox"); + + b.HasIndex("UserId", "IsUnread"); + + b.HasIndex("UserId", "SenderId"); + + b.HasIndex("UserId", "SentAtUtc"); + + b.ToTable("emails", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b => + { + b.Property("EmailId") + .HasColumnType("uuid"); + + b.Property("LabelId") + .HasColumnType("uuid"); + + b.HasKey("EmailId", "LabelId"); + + b.HasIndex("LabelId"); + + b.ToTable("email_labels", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.FeatureFlag", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserOverridable") + .HasColumnType("boolean"); + + b.HasKey("Key"); + + b.ToTable("feature_flags", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ColorHex") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GmailLabelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GmailLabelId") + .IsUnique(); + + b.ToTable("labels", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailCount") + .HasColumnType("integer"); + + b.Property("IsBulkSender") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" }); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("domains", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstMessageUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GmailThreadId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastMessageUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageCount") + .HasColumnType("integer"); + + b.Property("Snippet") + .HasColumnType("text"); + + b.Property("Subject") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GmailThreadId") + .IsUnique(); + + b.ToTable("threads", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("DomainId") + .HasColumnType("uuid"); + + b.Property("EmailCount") + .HasColumnType("integer"); + + b.Property("HasUnsubscribe") + .HasColumnType("boolean"); + + b.Property("LastReceivedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalSizeBytes") + .HasColumnType("bigint"); + + b.Property("UnreadCount") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Address"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Address"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Address"), new[] { "gin_trgm_ops" }); + + b.HasIndex("DisplayName"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("DisplayName"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("DisplayName"), new[] { "gin_trgm_ops" }); + + b.HasIndex("DomainId"); + + b.HasIndex("UserId", "Address") + .IsUnique(); + + b.HasIndex("UserId", "EmailCount"); + + b.ToTable("senders", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.SyncState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("LastHistoryId") + .HasColumnType("text"); + + b.Property("LastSuccessfulSyncUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncType") + .HasColumnType("integer"); + + b.Property("MessagesProcessed") + .HasColumnType("integer"); + + b.Property("ResumePageToken") + .HasColumnType("text"); + + b.Property("StartedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TotalMessagesEstimate") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("sync_states", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Confidence") + .HasColumnType("double precision"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailCount") + .HasColumnType("integer"); + + b.Property("LastAttemptUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Method") + .HasColumnType("integer"); + + b.Property("ResultMessage") + .HasColumnType("text"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UnsubscribeTarget") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SenderId"); + + b.HasIndex("UserId", "SenderId") + .IsUnique(); + + b.ToTable("unsubscribe_items", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessTokenExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DigestEnabled") + .HasColumnType("boolean"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("EncryptedRefreshToken") + .HasColumnType("bytea"); + + b.Property("GoogleSubjectId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastDigestSentUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastLoginUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PictureUrl") + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("GoogleSubjectId") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("AiOptIn") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PreferencesJson") + .HasColumnType("text"); + + b.Property("Theme") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId"); + + b.ToTable("user_settings", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("H") + .HasColumnType("integer"); + + b.Property("SettingsJson") + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Visible") + .HasColumnType("boolean"); + + b.Property("W") + .HasColumnType("integer"); + + b.Property("WidgetKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "WidgetKey") + .IsUnique(); + + b.ToTable("widget_layouts", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b => + { + b.HasOne("InboxIntel.Domain.Entities.Email", "Email") + .WithMany("Attachments") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b => + { + b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender") + .WithMany("Emails") + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InboxIntel.Domain.Entities.MailThread", "Thread") + .WithMany("Emails") + .HasForeignKey("ThreadId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InboxIntel.Domain.Entities.User", null) + .WithMany("Emails") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sender"); + + b.Navigation("Thread"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b => + { + b.HasOne("InboxIntel.Domain.Entities.Email", "Email") + .WithMany("EmailLabels") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InboxIntel.Domain.Entities.Label", "Label") + .WithMany("EmailLabels") + .HasForeignKey("LabelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + + b.Navigation("Label"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b => + { + b.HasOne("InboxIntel.Domain.Entities.MailDomain", "Domain") + .WithMany("Senders") + .HasForeignKey("DomainId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Domain"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b => + { + b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender") + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b => + { + b.HasOne("InboxIntel.Domain.Entities.User", "User") + .WithOne() + .HasForeignKey("InboxIntel.Domain.Entities.UserSetting", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b => + { + b.HasOne("InboxIntel.Domain.Entities.User", null) + .WithMany("WidgetLayouts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b => + { + b.Navigation("Attachments"); + + b.Navigation("EmailLabels"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b => + { + b.Navigation("EmailLabels"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b => + { + b.Navigation("Senders"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b => + { + b.Navigation("Emails"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b => + { + b.Navigation("Emails"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.User", b => + { + b.Navigation("Emails"); + + b.Navigation("WidgetLayouts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/InboxIntel.Infrastructure/Migrations/20260704141744_AddIsReadLater.cs b/src/InboxIntel.Infrastructure/Migrations/20260704141744_AddIsReadLater.cs new file mode 100644 index 0000000..80cedd4 --- /dev/null +++ b/src/InboxIntel.Infrastructure/Migrations/20260704141744_AddIsReadLater.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InboxIntel.Infrastructure.Migrations +{ + /// + public partial class AddIsReadLater : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsReadLater", + table: "emails", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsReadLater", + table: "emails"); + } + } +} diff --git a/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index c0bb4e9..686ff5e 100644 --- a/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using InboxIntel.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -147,6 +147,9 @@ namespace InboxIntel.Infrastructure.Migrations b.Property("IsInInbox") .HasColumnType("boolean"); + b.Property("IsReadLater") + .HasColumnType("boolean"); + b.Property("IsStarred") .HasColumnType("boolean"); diff --git a/src/InboxIntel.Infrastructure/Search/SearchService.cs b/src/InboxIntel.Infrastructure/Search/SearchService.cs index 0b8ba4b..3bd0556 100644 --- a/src/InboxIntel.Infrastructure/Search/SearchService.cs +++ b/src/InboxIntel.Infrastructure/Search/SearchService.cs @@ -52,8 +52,32 @@ public class SearchService : ISearchService q = q.Where(e => e.IsStarred == starred); if (r.IsTrashed is { } trashed) q = q.Where(e => e.IsTrashed == trashed); + if (r.IsImportant is { } important) + q = q.Where(e => e.IsImportant == important); + if (r.IsReadLater is { } readLater) + q = q.Where(e => e.IsReadLater == readLater); if (r.MinSizeBytes is { } minSize) q = q.Where(e => e.SizeEstimateBytes >= minSize); + // Unlabelled: emails with (no) USER labels. System labels (INBOX/SENT/…) don't count, + // so Unlabelled means "not filed into any user-created label". + if (r.HasUserLabels is { } hasUserLabels) + { + if (hasUserLabels) + q = q.Where(e => e.EmailLabels.Any(el => el.Label!.Type == "user")); + else + q = q.Where(e => !e.EmailLabels.Any(el => el.Label!.Type == "user")); + } + // Archive excludes Sent/Spam/Draft/Chat/Trash by label absence, not just "not in inbox". + if (r.ExcludeGmailLabels is { Count: > 0 } exclude) + { + var excludeUpper = exclude.Select(x => x.ToUpperInvariant()).ToList(); + var excludeIds = await _db.Labels + .Where(l => l.UserId == userId && excludeUpper.Contains(l.GmailLabelId.ToUpper())) + .Select(l => l.Id) + .ToListAsync(ct); + if (excludeIds.Count > 0) + q = q.Where(e => !e.EmailLabels.Any(el => excludeIds.Contains(el.LabelId))); + } if (!string.IsNullOrWhiteSpace(r.Category) && Enum.TryParse(r.Category, true, out var cat)) q = q.Where(e => e.Category == cat); if (!string.IsNullOrWhiteSpace(r.GmailLabel)) diff --git a/src/InboxIntel.Infrastructure/Sync/SyncService.cs b/src/InboxIntel.Infrastructure/Sync/SyncService.cs index 27798e9..0c1b759 100644 --- a/src/InboxIntel.Infrastructure/Sync/SyncService.cs +++ b/src/InboxIntel.Infrastructure/Sync/SyncService.cs @@ -228,9 +228,34 @@ public class SyncService : ISyncService await _db.SaveChangesAsync(ct); } - /// Resolves domain/sender/thread, then inserts the email and attachment metadata. + // Per-sync-run cache of the user's GmailLabelId -> local Label.Id, so email/label linkage + // costs no extra query per message. Populated lazily; labels are synced before messages. + private Dictionary? _labelCache; + private Guid _labelCacheUserId; + + private async Task> GetLabelMapAsync(Guid userId, CancellationToken ct) + { + if (_labelCache is null || _labelCacheUserId != userId) + { + _labelCache = await _db.Labels + .Where(l => l.UserId == userId) + .ToDictionaryAsync(l => l.GmailLabelId, l => l.Id, ct); + _labelCacheUserId = userId; + } + return _labelCache; + } + + /// Resolves domain/sender/thread, then upserts the email, its labels, and + /// attachment metadata. Idempotent: re-syncing a message replaces the prior row (and its + /// labels) rather than duplicating it. private async Task UpsertMessageAsync(Guid userId, GmailMessageDetail d, CancellationToken ct) { + // True upsert: drop any existing copy first so labels/flags re-populate cleanly and + // an incrementally-changed message can't be duplicated. EmailLabels/Attachments cascade. + var prior = await _db.Emails.FirstOrDefaultAsync( + e => e.UserId == userId && e.GmailMessageId == d.GmailMessageId, ct); + if (prior is not null) _db.Emails.Remove(prior); + var sender = await ResolveSenderAsync(userId, d.FromAddress, d.FromDisplayName, ct); var thread = await ResolveThreadAsync(userId, d.GmailThreadId, d.Subject, d.Snippet, d.SentAtUtc, ct); @@ -256,6 +281,7 @@ public class SyncService : ISyncService IsInInbox = d.LabelIds.Contains("INBOX"), IsStarred = d.LabelIds.Contains("STARRED"), IsImportant = d.LabelIds.Contains("IMPORTANT"), + IsTrashed = d.LabelIds.Contains("TRASH"), HasAttachments = d.HasAttachments, HasListUnsubscribe = d.HasListUnsubscribe, ListUnsubscribeRaw = Trunc(d.ListUnsubscribeRaw, 2048), @@ -264,6 +290,14 @@ public class SyncService : ISyncService }; _db.Emails.Add(email); + // Link the email to its Gmail labels (system + user) so label-based folders — Sent, + // Spam, and "Unlabelled" (no user label) — resolve correctly. Previously no EmailLabel + // rows were ever created, so every label folder was empty and every email looked unlabelled. + var labelMap = await GetLabelMapAsync(userId, ct); + foreach (var gmailLabelId in d.LabelIds.Distinct()) + if (labelMap.TryGetValue(gmailLabelId, out var localLabelId)) + _db.Set().Add(new EmailLabel { EmailId = email.Id, LabelId = localLabelId }); + foreach (var (fileName, mime, size, attId) in d.Attachments) { _db.Attachments.Add(new Attachment diff --git a/tests/InboxIntel.IntegrationTests/CategoryFilterTests.cs b/tests/InboxIntel.IntegrationTests/CategoryFilterTests.cs new file mode 100644 index 0000000..01fdba6 --- /dev/null +++ b/tests/InboxIntel.IntegrationTests/CategoryFilterTests.cs @@ -0,0 +1,115 @@ +using FluentAssertions; +using InboxIntel.Application.Abstractions; +using InboxIntel.Application.DTOs; +using InboxIntel.Domain.Entities; +using InboxIntel.Infrastructure.Persistence; +using InboxIntel.Infrastructure.Search; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace InboxIntel.IntegrationTests; + +/// +/// PHASE 2 (smart-category correctness): each reported folder bug — Archive leaking Sent, +/// Read Later / Pinned / Unlabelled returning everything, Old Mail duration — is fixed at the +/// filter source. These prove the SearchService filters that back the corrected folder mappings. +/// +public class CategoryFilterTests +{ + private sealed class FakeCurrentUser : ICurrentUser + { + public Guid UserId { get; set; } + public bool IsAuthenticated => UserId != Guid.Empty; + } + + private static AppDbContext Db(string name, Guid uid) => + new(new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options, + new FakeCurrentUser { UserId = uid }); + + // A small fixture: an inbox email, a sent email (SENT label), one important, one read-later, + // and one filed under a user label. + private static async Task SeedAsync(string name) + { + var uid = Guid.NewGuid(); + using var db = Db(name, Guid.Empty); + var sender = new Sender { UserId = uid, Address = "s@x.x" }; + db.Senders.Add(sender); + var sentLabel = new Label { UserId = uid, GmailLabelId = "SENT", Name = "Sent", Type = "system" }; + var userLabel = new Label { UserId = uid, GmailLabelId = "Label_1", Name = "Projects", Type = "user" }; + db.Labels.AddRange(sentLabel, userLabel); + + Email E(string id, Action cfg) + { + var e = new Email { UserId = uid, GmailMessageId = id, Subject = id, Sender = sender, SentAtUtc = DateTimeOffset.UtcNow }; + cfg(e); + db.Emails.Add(e); + return e; + } + var inbox = E("inbox", e => e.IsInInbox = true); + var sent = E("sent", e => e.IsInInbox = false); // archived-looking, but it's Sent + var important = E("important", e => e.IsImportant = true); + var later = E("later", e => e.IsReadLater = true); + var filed = E("filed", e => e.IsInInbox = false); + await db.SaveChangesAsync(); + + db.Set().AddRange( + new EmailLabel { EmailId = sent.Id, LabelId = sentLabel.Id }, + new EmailLabel { EmailId = filed.Id, LabelId = userLabel.Id }); + await db.SaveChangesAsync(); + return uid; + } + + private static async Task> RunAsync(string name, Guid uid, SearchRequestDto req) + { + using var db = Db(name, uid); + var res = await new SearchService(db).SearchAsync(uid, req); + return res.Items.Select(i => i.GmailMessageId).OrderBy(x => x).ToList(); + } + + private static SearchRequestDto Base() => new(null, null, null, null, null, null, null, false, 1, 50); + + [Fact] + public async Task Archive_excludes_sent_mail() + { + var name = nameof(Archive_excludes_sent_mail); + var uid = await SeedAsync(name); + // Archive: not-in-inbox, not-trashed, excluding SENT — must NOT contain "sent". + var items = await RunAsync(name, uid, Base() with + { + IsInInbox = false, + IsTrashed = false, + ExcludeGmailLabels = new[] { "SENT", "SPAM", "DRAFT" } + }); + items.Should().NotContain("sent"); + items.Should().Contain("filed"); // a genuinely archived, user-filed mail stays + } + + [Fact] + public async Task Pinned_returns_only_important_not_everything() + { + var name = nameof(Pinned_returns_only_important_not_everything); + var uid = await SeedAsync(name); + var items = await RunAsync(name, uid, Base() with { IsImportant = true }); + items.Should().Equal("important"); + } + + [Fact] + public async Task ReadLater_returns_only_flagged_not_everything() + { + var name = nameof(ReadLater_returns_only_flagged_not_everything); + var uid = await SeedAsync(name); + var items = await RunAsync(name, uid, Base() with { IsReadLater = true }); + items.Should().Equal("later"); + } + + [Fact] + public async Task Unlabelled_excludes_user_labelled_mail() + { + var name = nameof(Unlabelled_excludes_user_labelled_mail); + var uid = await SeedAsync(name); + // "filed" has a user label → must be absent; everything else (no user label) present. + var items = await RunAsync(name, uid, Base() with { HasUserLabels = false }); + items.Should().NotContain("filed"); + items.Should().Contain("inbox"); + } +}