a8e2f4dc4a
b3 of the multi-provider email roadmap. Adds ImapConnection model + table (reconciler pattern, SQLite+MySQL), ImapService (MailKit-backed IMAP client), ImapProvider implementing the existing IEmailProvider contract unchanged, and ImapController for credential-based connect (no OAuth — user supplies host/username/password directly, verified by a live connect before storage). Scope, documented inline with ponytail: comments: - INBOX only, no multi-folder support. - Thread grouping approximates the References/In-Reply-To chain root rather than the IMAP THREAD extension, which not every server implements. - External message ids are IMAP UIDs, scoped to the connection's current UIDVALIDITY. Security: ran the security-audit skill against this diff (credential handling + arbitrary-host connect is exactly the class of change the standing security gate exists for). Found and fixed a real SSRF: the connect endpoint let an authenticated user point the server at an arbitrary host:port with no internal-range check, and connect-vs-auth failure was distinguishable to the caller -- together a working oracle to fingerprint internal services (loopback/RFC1918/link-local/cloud metadata) from the server's network position. Fixed with EnsureHostIsExternalAsync (DNS-resolve + reject internal ranges, re-checked on every reconnect to close the DNS-rebinding gap) and a single generic failure message that no longer distinguishes connect vs auth failure. 7 regression tests added. Dependency: MailKit 4.17.0 (MIT license) on JobTrackerBackend.csproj -- stdlib has no IMAP client; hand-rolling IMAP4rev1 (TLS, SASL, MIME parsing) would be a large, security-sensitive protocol implementation nobody asked for, so this is the correct dependency, not a stdlib substitute. 168/168 green (161 existing + 7 new SSRF regression tests; the earlier 14 IMAP feature tests are included in the 161). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
147 lines
6.2 KiB
C#
147 lines
6.2 KiB
C#
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using JobTrackerApi.Models;
|
|
|
|
namespace JobTrackerApi.Data
|
|
{
|
|
public class JobTrackerContext : IdentityDbContext<ApplicationUser>
|
|
{
|
|
public string? CurrentUserId { get; }
|
|
|
|
public JobTrackerContext(DbContextOptions<JobTrackerContext> options, JobTrackerApi.Services.ICurrentUserService currentUser) : base(options)
|
|
{
|
|
CurrentUserId = currentUser.UserId;
|
|
}
|
|
|
|
public DbSet<Company> Companies => Set<Company>();
|
|
public DbSet<JobApplication> JobApplications => Set<JobApplication>();
|
|
public DbSet<Correspondence> Correspondences => Set<Correspondence>();
|
|
public DbSet<GmailConnection> GmailConnections => Set<GmailConnection>();
|
|
public DbSet<GmailReviewDecision> GmailReviewDecisions => Set<GmailReviewDecision>();
|
|
public DbSet<MicrosoftGraphConnection> MicrosoftGraphConnections => Set<MicrosoftGraphConnection>();
|
|
public DbSet<ImapConnection> ImapConnections => Set<ImapConnection>();
|
|
public DbSet<Attachment> Attachments => Set<Attachment>();
|
|
public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>();
|
|
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
|
|
public DbSet<SystemEmailSettings> SystemEmailSettings => Set<SystemEmailSettings>();
|
|
public DbSet<JobEvent> JobEvents => Set<JobEvent>();
|
|
public DbSet<CvUploadArtifact> CvUploadArtifacts => Set<CvUploadArtifact>();
|
|
public DbSet<CvExtractionRun> CvExtractionRuns => Set<CvExtractionRun>();
|
|
public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>();
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
base.OnModelCreating(modelBuilder);
|
|
|
|
modelBuilder.Entity<Company>()
|
|
.HasQueryFilter(c => CurrentUserId != null && c.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<JobApplication>()
|
|
.HasQueryFilter(j => CurrentUserId != null && j.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<UserRuleSettings>()
|
|
.HasKey(x => x.OwnerUserId);
|
|
|
|
modelBuilder.Entity<UserRuleSettings>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<RuleSettings>()
|
|
.HasData(new RuleSettings { Id = 1 });
|
|
|
|
modelBuilder.Entity<JobApplication>()
|
|
.HasOne(j => j.Company)
|
|
.WithMany(c => c.Jobs)
|
|
.HasForeignKey(j => j.CompanyId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
modelBuilder.Entity<JobApplication>()
|
|
.HasIndex(j => j.OwnerUserId);
|
|
|
|
// Owner-prefixed composite indexes for the tenant-scoped hot paths. Every
|
|
// JobApplication query is scoped by the OwnerUserId global filter first, then
|
|
// filtered by IsDeleted (list/board/stats/analytics) or FollowUpAt (reminders).
|
|
// Status is intentionally excluded from the index because Pomelo maps the
|
|
// unbounded string column to longtext, which MariaDB cannot index without a
|
|
// prefix length. The actual index DDL is applied idempotently in
|
|
// StartupInitializationExtensions (this repo provisions schema via that
|
|
// reconciler, not via the EF ModelSnapshot, which is stale).
|
|
modelBuilder.Entity<JobApplication>()
|
|
.HasIndex(j => new { j.OwnerUserId, j.IsDeleted });
|
|
|
|
modelBuilder.Entity<JobApplication>()
|
|
.HasIndex(j => new { j.OwnerUserId, j.FollowUpAt });
|
|
|
|
modelBuilder.Entity<Company>()
|
|
.HasIndex(c => c.OwnerUserId);
|
|
|
|
modelBuilder.Entity<Correspondence>()
|
|
.HasQueryFilter(c => CurrentUserId != null && c.JobApplication.OwnerUserId == CurrentUserId)
|
|
.HasOne(c => c.JobApplication)
|
|
.WithMany(j => j.Messages)
|
|
.HasForeignKey(c => c.JobApplicationId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
modelBuilder.Entity<GmailConnection>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<GmailReviewDecision>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Ignore<CorrespondenceAttachmentMetadata>();
|
|
|
|
modelBuilder.Entity<GmailConnection>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.GmailAddress })
|
|
.IsUnique();
|
|
|
|
modelBuilder.Entity<GmailConnection>()
|
|
.HasIndex(x => x.OwnerUserId);
|
|
|
|
modelBuilder.Entity<Attachment>()
|
|
.HasOne(a => a.JobApplication)
|
|
.WithMany(j => j.Attachments)
|
|
.HasForeignKey(a => a.JobApplicationId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
modelBuilder.Entity<JobEvent>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.JobApplication.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<JobEvent>()
|
|
.HasOne(e => e.JobApplication)
|
|
.WithMany(j => j.Events)
|
|
.HasForeignKey(e => e.JobApplicationId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
modelBuilder.Entity<CvUploadArtifact>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<CvUploadArtifact>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.UploadedAtUtc });
|
|
|
|
modelBuilder.Entity<CvExtractionRun>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<CvExtractionRun>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.StartedAtUtc });
|
|
|
|
modelBuilder.Entity<CvExtractionRun>()
|
|
.HasOne(x => x.Artifact)
|
|
.WithMany()
|
|
.HasForeignKey(x => x.ArtifactId)
|
|
.OnDelete(DeleteBehavior.SetNull);
|
|
|
|
modelBuilder.Entity<TailoredCvDraft>()
|
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
|
|
|
modelBuilder.Entity<TailoredCvDraft>()
|
|
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId })
|
|
.IsUnique();
|
|
|
|
modelBuilder.Entity<TailoredCvDraft>()
|
|
.HasOne(x => x.JobApplication)
|
|
.WithOne(j => j.TailoredCvDraft)
|
|
.HasForeignKey<TailoredCvDraft>(x => x.JobApplicationId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
}
|
|
}
|
|
}
|