fix(categories): correct all 7 smart-folder bugs at source (PHASE 2) #38
@@ -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 ──
|
||||
|
||||
@@ -72,6 +72,22 @@ public class EmailController : ApiControllerBase
|
||||
[HttpPost("{id:guid}/untrash")]
|
||||
public Task<IActionResult> Untrash(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Archive, ct);
|
||||
|
||||
/// <summary>Toggle the local-only Read Later marker (no Gmail side effect).</summary>
|
||||
[HttpPost("{id:guid}/readlater")]
|
||||
public Task<IActionResult> ReadLater(Guid id, CancellationToken ct) => SetReadLater(id, true, ct);
|
||||
|
||||
[HttpPost("{id:guid}/unreadlater")]
|
||||
public Task<IActionResult> UnreadLater(Guid id, CancellationToken ct) => SetReadLater(id, false, ct);
|
||||
|
||||
private async Task<IActionResult> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
||||
@@ -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<string>? 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
|
||||
|
||||
@@ -40,6 +40,10 @@ public class Email : AuditableEntity
|
||||
public bool IsTrashed { get; set; }
|
||||
public bool HasAttachments { get; set; }
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public bool IsReadLater { get; set; }
|
||||
|
||||
// Unsubscribe signals captured at parse time.
|
||||
public bool HasListUnsubscribe { get; set; }
|
||||
public string? ListUnsubscribeRaw { get; set; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
Generated
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
Generated
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
Generated
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
Generated
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
+834
@@ -0,0 +1,834 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateOnly>("Day")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("HourHistogramJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("NewsletterCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TotalReceived")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("TotalSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("TotalUnread")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("EmailId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("GmailAttachmentId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BodyText")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Vector>("Embedding")
|
||||
.HasColumnType("vector(768)");
|
||||
|
||||
b.Property<string>("GmailMessageId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<bool>("HasAttachments")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HasListUnsubscribe")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsImportant")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsInInbox")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsReadLater")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsTrashed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsUnread")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("ListUnsubscribeRaw")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReceivedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchVector")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true);
|
||||
|
||||
b.Property<Guid>("SenderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("SentAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("SizeEstimateBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Snippet")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<bool>("SupportsOneClickUnsubscribe")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid>("ThreadId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("EmailId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("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<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("UserOverridable")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("feature_flags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ColorHex")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GmailLabelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("EmailCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsBulkSender")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("FirstMessageUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GmailThreadId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastMessageUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("MessageCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Snippet")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Address")
|
||||
.IsRequired()
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<Guid>("DomainId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("EmailCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("HasUnsubscribe")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastReceivedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("TotalSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("UnreadCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ConsecutiveFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastError")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<string>("LastHistoryId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSuccessfulSyncUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("LastSyncType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("MessagesProcessed")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ResumePageToken")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("StartedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TotalMessagesEstimate")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("Confidence")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("EmailCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAttemptUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Method")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ResultMessage")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("SenderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UnsubscribeTarget")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("AccessTokenExpiresAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("DigestEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)");
|
||||
|
||||
b.Property<byte[]>("EncryptedRefreshToken")
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<string>("GoogleSubjectId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastDigestSentUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastLoginUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PictureUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("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<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("AiOptIn")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PreferencesJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Theme")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("UserId");
|
||||
|
||||
b.ToTable("user_settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("H")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SettingsJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Visible")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("W")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("WidgetKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int>("X")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace InboxIntel.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddIsReadLater : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsReadLater",
|
||||
table: "emails",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsReadLater",
|
||||
table: "emails");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -147,6 +147,9 @@ namespace InboxIntel.Infrastructure.Migrations
|
||||
b.Property<bool>("IsInInbox")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsReadLater")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
|
||||
@@ -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<EmailCategory>(r.Category, true, out var cat))
|
||||
q = q.Where(e => e.Category == cat);
|
||||
if (!string.IsNullOrWhiteSpace(r.GmailLabel))
|
||||
|
||||
@@ -228,9 +228,34 @@ public class SyncService : ISyncService
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Resolves domain/sender/thread, then inserts the email and attachment metadata.</summary>
|
||||
// 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<string, Guid>? _labelCache;
|
||||
private Guid _labelCacheUserId;
|
||||
|
||||
private async Task<Dictionary<string, Guid>> 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;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<EmailLabel>().Add(new EmailLabel { EmailId = email.Id, LabelId = localLabelId });
|
||||
|
||||
foreach (var (fileName, mime, size, attId) in d.Attachments)
|
||||
{
|
||||
_db.Attachments.Add(new Attachment
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<AppDbContext>().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<Guid> 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<Email> 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<EmailLabel>().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<List<string>> 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user