Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8e2f4dc4a | |||
| 8edbdceee9 | |||
| 4f98195592 | |||
| b1d5bd516e | |||
| 3eef06e906 | |||
| daa9694bc7 |
@@ -70,7 +70,12 @@ jobs:
|
||||
CI: 'false'
|
||||
GENERATE_SOURCEMAP: 'false'
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: npm run build
|
||||
# CRA's build (Terser minify + fork-ts-checker workers) has repeatedly died silently on
|
||||
# this runner with no error output (OOM/SIGSEGV signature — same resource-starved-runner
|
||||
# class as the npm ci and dotnet-install flakes elsewhere in this workflow). Retry once.
|
||||
run: |
|
||||
npm run build \
|
||||
|| ( echo "Frontend build failed ($?) — retrying once..." && npm run build )
|
||||
|
||||
deploy:
|
||||
needs: test
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace JobTrackerApi.Data
|
||||
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>();
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class ImapControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Status_returns_connection_fields_for_connected_account()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ImapConnection
|
||||
{
|
||||
OwnerUserId = "user-1",
|
||||
Host = "imap.example.test",
|
||||
Port = 993,
|
||||
UseSsl = true,
|
||||
Username = "user@example.test",
|
||||
ConnectedAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
LastSyncStatus = "ok"
|
||||
});
|
||||
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
var result = await controller.Status(CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<ImapController.ImapConnectionStatusDto>(ok.Value);
|
||||
Assert.True(payload.Connected);
|
||||
Assert.Equal("imap.example.test", payload.Host);
|
||||
Assert.Equal(993, payload.Port);
|
||||
Assert.Equal("user@example.test", payload.Username);
|
||||
Assert.Equal("ok", payload.LastSyncStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_reports_not_connected_when_no_connection_exists()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ImapConnection?)null);
|
||||
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
var result = await controller.Status(CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<ImapController.ImapConnectionStatusDto>(ok.Value);
|
||||
Assert.False(payload.Connected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", 993, "user", "pass", "Host is required.")]
|
||||
[InlineData("imap.example.test", 0, "user", "pass", "Valid port is required.")]
|
||||
[InlineData("imap.example.test", 993, "", "pass", "Username is required.")]
|
||||
[InlineData("imap.example.test", 993, "user", "", "Password is required.")]
|
||||
public async Task Connect_rejects_missing_fields(string host, int port, string username, string password, string expectedError)
|
||||
{
|
||||
var imap = new Mock<IImapService>(MockBehavior.Strict);
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
|
||||
var result = await controller.Connect(new ImapController.ImapConnectRequest(host, port, true, username, password), CancellationToken.None);
|
||||
|
||||
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
|
||||
Assert.Equal(expectedError, badRequest.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_returns_bad_request_when_service_rejects_credentials()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.ConnectAsync("user-1", "imap.example.test", 993, true, "user", "wrong", It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("IMAP authentication failed: bad credentials"));
|
||||
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
var result = await controller.Connect(new ImapController.ImapConnectRequest("imap.example.test", 993, true, "user", "wrong"), CancellationToken.None);
|
||||
|
||||
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
|
||||
Assert.Contains("authentication failed", (string)badRequest.Value!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_succeeds_and_returns_username()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.ConnectAsync("user-1", "imap.example.test", 993, true, "user@example.test", "correct", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ImapConnectResult("user@example.test"));
|
||||
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
var result = await controller.Connect(new ImapController.ImapConnectRequest("imap.example.test", 993, true, "user@example.test", "correct"), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var username = ok.Value!.GetType().GetProperty("username")!.GetValue(ok.Value) as string;
|
||||
Assert.Equal("user@example.test", username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disconnect_calls_service_for_authenticated_user()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.DisconnectAsync("user-1", It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
||||
|
||||
var controller = CreateController(imap.Object, "user-1");
|
||||
var result = await controller.Disconnect(CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
imap.Verify(service => service.DisconnectAsync("user-1", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
private static ImapController CreateController(IImapService imap, string userId)
|
||||
{
|
||||
return new ImapController(imap)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId)
|
||||
}, "test"))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Services.EmailProviders;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class ImapProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProviderKey_is_imap()
|
||||
{
|
||||
var provider = new ImapProvider(Mock.Of<IImapService>());
|
||||
Assert.Equal("imap", provider.ProviderKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConnectionAsync_maps_username_onto_neutral_shape()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new JobTrackerApi.Models.ImapConnection { OwnerUserId = "user-1", Username = "user@example.test" });
|
||||
|
||||
var provider = new ImapProvider(imap.Object);
|
||||
var connection = await provider.GetConnectionAsync("user-1", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(connection);
|
||||
Assert.Equal("imap", connection!.ProviderKey);
|
||||
Assert.Equal("user@example.test", connection.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConnectionAsync_returns_null_when_not_connected()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((JobTrackerApi.Models.ImapConnection?)null);
|
||||
|
||||
var provider = new ImapProvider(imap.Object);
|
||||
var connection = await provider.GetConnectionAsync("user-1", CancellationToken.None);
|
||||
|
||||
Assert.Null(connection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_maps_thread_key_onto_neutral_thread_id()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.ListMessagesAsync("user-1", "recruiter", 10, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ImapMessageSummary>
|
||||
{
|
||||
new("42", "root-msg-id@example.test", "Interview", "them@company.test", "me@example.test", DateTimeOffset.UtcNow, "snippet")
|
||||
});
|
||||
|
||||
var provider = new ImapProvider(imap.Object);
|
||||
var results = await provider.SearchAsync("user-1", "recruiter", 10, CancellationToken.None);
|
||||
|
||||
var summary = Assert.Single(results);
|
||||
Assert.Equal("42", summary.Id);
|
||||
Assert.Equal("root-msg-id@example.test", summary.ThreadId);
|
||||
Assert.Equal("Interview", summary.Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMessageAsync_maps_content_id_onto_neutral_external_attachment_id()
|
||||
{
|
||||
var imap = new Mock<IImapService>();
|
||||
imap.Setup(service => service.GetMessageAsync("user-1", "42", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ImapMessageDetail(
|
||||
"42", "root-msg-id@example.test", "Offer", "them@company.test", "me@example.test", DateTimeOffset.UtcNow, "snippet",
|
||||
"body text", "<p>body</p>", new List<string>(),
|
||||
new List<ImapMessageAttachment> { new("resume.pdf", "application/pdf", 1024, "cid-1", false) }));
|
||||
|
||||
var provider = new ImapProvider(imap.Object);
|
||||
var detail = await provider.GetMessageAsync("user-1", "42", CancellationToken.None);
|
||||
|
||||
Assert.Equal("root-msg-id@example.test", detail.ThreadId);
|
||||
var attachment = Assert.Single(detail.Attachments);
|
||||
Assert.Equal("resume.pdf", attachment.FileName);
|
||||
Assert.Equal("cid-1", attachment.ExternalAttachmentId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.IO;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
// Regression coverage for the SSRF guard in ImapService: an authenticated user's IMAP "connect"
|
||||
// target must not be usable to probe loopback/RFC1918/link-local/cloud-metadata addresses.
|
||||
public sealed class ImapServiceSsrfGuardTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1")]
|
||||
[InlineData("localhost")]
|
||||
[InlineData("10.0.0.5")]
|
||||
[InlineData("172.16.0.5")]
|
||||
[InlineData("192.168.1.5")]
|
||||
[InlineData("169.254.169.254")] // cloud metadata endpoint
|
||||
public async Task ConnectAsync_rejects_internal_and_metadata_hosts(string host)
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.ConnectAsync("user-1", host, 993, true, "user", "password", CancellationToken.None));
|
||||
|
||||
// Message must not leak connect-vs-auth distinction (that's the oracle this guard closes).
|
||||
Assert.DoesNotContain("resolve", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("reachable", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_rejects_unresolvable_host_without_leaking_dns_detail()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.ConnectAsync("user-1", "this-host-does-not-exist.invalid", 993, true, "user", "password", CancellationToken.None));
|
||||
|
||||
Assert.Equal("Could not connect to that IMAP server with the given credentials. Check host, port, and password.", ex.Message);
|
||||
}
|
||||
|
||||
private static ImapService CreateService()
|
||||
{
|
||||
var db = TestHostFactory.CreateInMemoryDb();
|
||||
var protectionProvider = DataProtectionProvider.Create(new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"jobtracker-tests-{Guid.NewGuid():N}")));
|
||||
return new ImapService(db, protectionProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Generic IMAP connection lifecycle for mailboxes with no dedicated OAuth provider. Unlike
|
||||
/// Gmail/Microsoft, there's no OAuth redirect — the caller submits host/username/password once,
|
||||
/// <see cref="IImapService"/> verifies them by connecting, then encrypts and stores them.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/imap")]
|
||||
[Authorize]
|
||||
public sealed class ImapController : ControllerBase
|
||||
{
|
||||
private readonly IImapService _imap;
|
||||
|
||||
public ImapController(IImapService imap)
|
||||
{
|
||||
_imap = imap;
|
||||
}
|
||||
|
||||
public sealed record ImapConnectRequest(string Host, int Port, bool UseSsl, string Username, string Password);
|
||||
|
||||
public sealed record ImapConnectionStatusDto(
|
||||
bool Connected,
|
||||
string? Host,
|
||||
int? Port,
|
||||
bool? UseSsl,
|
||||
string? Username,
|
||||
DateTimeOffset? ConnectedAt,
|
||||
DateTimeOffset? LastSyncedAt,
|
||||
DateTimeOffset? LastSyncAttemptedAt,
|
||||
DateTimeOffset? LastSyncSucceededAt,
|
||||
string? LastSyncMode,
|
||||
string? LastSyncSource,
|
||||
string? LastSyncStatus,
|
||||
string? LastSyncError);
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<ActionResult<ImapConnectionStatusDto>> Status(CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
var connection = await _imap.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||
return Ok(new ImapConnectionStatusDto(
|
||||
connection is not null,
|
||||
connection?.Host,
|
||||
connection?.Port,
|
||||
connection?.UseSsl,
|
||||
connection?.Username,
|
||||
connection?.ConnectedAt,
|
||||
connection?.LastSyncedAt,
|
||||
connection?.LastSyncAttemptedAt,
|
||||
connection?.LastSyncSucceededAt,
|
||||
connection?.LastSyncMode,
|
||||
connection?.LastSyncSource,
|
||||
connection?.LastSyncStatus,
|
||||
connection?.LastSyncError));
|
||||
}
|
||||
|
||||
[HttpPost("connect")]
|
||||
public async Task<IActionResult> Connect([FromBody] ImapConnectRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Host)) return BadRequest("Host is required.");
|
||||
if (request.Port <= 0 || request.Port > 65535) return BadRequest("Valid port is required.");
|
||||
if (string.IsNullOrWhiteSpace(request.Username)) return BadRequest("Username is required.");
|
||||
if (string.IsNullOrWhiteSpace(request.Password)) return BadRequest("Password is required.");
|
||||
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
try
|
||||
{
|
||||
var result = await _imap.ConnectAsync(ownerUserId, request.Host, request.Port, request.UseSsl, request.Username, request.Password, cancellationToken);
|
||||
return Ok(new { username = result.Username });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("connection")]
|
||||
public async Task<IActionResult> Disconnect(CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
await _imap.DisconnectAsync(ownerUserId, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private string GetRequiredOwnerUserId()
|
||||
{
|
||||
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
|
||||
?? throw new InvalidOperationException("Authenticated user id is missing.");
|
||||
}
|
||||
}
|
||||
@@ -166,10 +166,12 @@ builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
||||
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
|
||||
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
|
||||
builder.Services.AddScoped<IMicrosoftGraphOAuthService, MicrosoftGraphOAuthService>();
|
||||
builder.Services.AddScoped<IImapService, ImapService>();
|
||||
|
||||
// Provider-neutral email seam (multi-provider: Gmail + Microsoft Graph today; IMAP / manual next).
|
||||
// Provider-neutral email seam (multi-provider: Gmail + Microsoft Graph + IMAP today; manual next).
|
||||
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.GmailProvider>();
|
||||
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.MicrosoftGraphProvider>();
|
||||
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.ImapProvider>();
|
||||
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProviderRegistry, JobTrackerApi.Services.EmailProviders.EmailProviderRegistry>();
|
||||
|
||||
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using JobTrackerApi.Services;
|
||||
|
||||
namespace JobTrackerApi.Services.EmailProviders
|
||||
{
|
||||
/// <summary>
|
||||
/// Generic IMAP implementation of <see cref="IEmailProvider"/> for mailboxes with no
|
||||
/// dedicated OAuth provider. Adapts <see cref="IImapService"/> to the provider-neutral
|
||||
/// contract, mapping IMAP DTOs to the neutral shapes.
|
||||
/// </summary>
|
||||
public sealed class ImapProvider : IEmailProvider
|
||||
{
|
||||
private readonly IImapService _imap;
|
||||
|
||||
public ImapProvider(IImapService imap)
|
||||
{
|
||||
_imap = imap;
|
||||
}
|
||||
|
||||
public string ProviderKey => "imap";
|
||||
|
||||
public async Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _imap.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||
return connection is null ? null : new EmailConnectionInfo("imap", connection.Username ?? "");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
|
||||
{
|
||||
var messages = await _imap.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken);
|
||||
return messages.Select(ToSummary).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken)
|
||||
{
|
||||
var messages = await _imap.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
|
||||
return messages.Select(ToSummary).ToList();
|
||||
}
|
||||
|
||||
public async Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var detail = await _imap.GetMessageAsync(ownerUserId, messageId, cancellationToken);
|
||||
var attachments = detail.Attachments
|
||||
.Select(a => new EmailAttachmentRef(a.FileName, a.MimeType, a.SizeBytes, a.ContentId, a.Inline))
|
||||
.ToList();
|
||||
|
||||
return new EmailMessageDetail(
|
||||
detail.Id,
|
||||
detail.ThreadKey,
|
||||
detail.Subject,
|
||||
detail.From,
|
||||
detail.To,
|
||||
detail.Date,
|
||||
detail.Snippet,
|
||||
detail.BodyText,
|
||||
detail.BodyHtml,
|
||||
detail.Labels,
|
||||
attachments);
|
||||
}
|
||||
|
||||
private static EmailMessageSummary ToSummary(ImapMessageSummary m)
|
||||
=> new(m.Id, m.ThreadKey, m.Subject, m.From, m.To, m.Date, m.Snippet);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Search;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MimeKit;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public interface IImapService
|
||||
{
|
||||
Task<ImapConnectResult> ConnectAsync(string ownerUserId, string host, int port, bool useSsl, string username, string password, CancellationToken cancellationToken);
|
||||
Task<ImapConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<ImapMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<ImapMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadKey, CancellationToken cancellationToken);
|
||||
Task<ImapMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record ImapConnectResult(string Username);
|
||||
public sealed record ImapMessageSummary(string Id, string ThreadKey, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
|
||||
public sealed record ImapMessageAttachment(string? FileName, string? MimeType, long? SizeBytes, string? ContentId, bool Inline);
|
||||
public sealed record ImapMessageDetail(string Id, string ThreadKey, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml, IReadOnlyList<string> Labels, IReadOnlyList<ImapMessageAttachment> Attachments);
|
||||
|
||||
/// <summary>
|
||||
/// Generic IMAP mail access for "any provider not explicitly supported" (Gmail/Microsoft have
|
||||
/// their own OAuth-based providers). Auth is direct host/username/password rather than OAuth —
|
||||
/// there's no connect-url/callback dance, the caller submits credentials once and they're
|
||||
/// encrypted at rest the same way Gmail/Microsoft's refresh tokens are.
|
||||
///
|
||||
/// ponytail: scoped to INBOX only, and "thread" is approximated from the References/In-Reply-To
|
||||
/// headers (the root Message-Id) rather than the server-side IMAP THREAD extension, which not
|
||||
/// every provider implements. Good enough for "show me the other messages in this conversation";
|
||||
/// upgrade to THREAD/SORT if a target provider needs cross-folder or extension-grade threading.
|
||||
/// External message ids are "{UID}" scoped to INBOX under the connection's current UIDVALIDITY —
|
||||
/// they are not stable across a UIDVALIDITY change (rare: a full mailbox reset on the server).
|
||||
/// </summary>
|
||||
public sealed class ImapService : IImapService
|
||||
{
|
||||
private const int ThreadScanWindow = 200;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly IDataProtector _protector;
|
||||
|
||||
public ImapService(JobTrackerContext db, IDataProtectionProvider protectionProvider)
|
||||
{
|
||||
_db = db;
|
||||
_protector = protectionProvider.CreateProtector("imap-credentials-v1");
|
||||
}
|
||||
|
||||
public async Task<ImapConnectResult> ConnectAsync(string ownerUserId, string host, int port, bool useSsl, string username, string password, CancellationToken cancellationToken)
|
||||
{
|
||||
host = host.Trim();
|
||||
username = username.Trim();
|
||||
if (host.Length == 0) throw new InvalidOperationException("IMAP host is required.");
|
||||
if (username.Length == 0) throw new InvalidOperationException("IMAP username is required.");
|
||||
if (string.IsNullOrEmpty(password)) throw new InvalidOperationException("IMAP password is required.");
|
||||
|
||||
// Verify the credentials actually work before persisting them. Failure detail is
|
||||
// intentionally generic (not the raw MailKit exception) so a "connect" attempt can't be
|
||||
// used as a distinguishable oracle to fingerprint what's listening on a given host:port.
|
||||
using (var client = new ImapClient())
|
||||
{
|
||||
try
|
||||
{
|
||||
await EnsureHostIsExternalAsync(host, cancellationToken);
|
||||
await client.ConnectAsync(host, port, useSsl, cancellationToken);
|
||||
await client.AuthenticateAsync(username, password, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
throw new InvalidOperationException("Could not connect to that IMAP server with the given credentials. Check host, port, and password.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (client.IsConnected)
|
||||
{
|
||||
await client.DisconnectAsync(true, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var existing = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new ImapConnection { OwnerUserId = ownerUserId };
|
||||
_db.ImapConnections.Add(existing);
|
||||
}
|
||||
|
||||
existing.Host = host;
|
||||
existing.Port = port;
|
||||
existing.UseSsl = useSsl;
|
||||
existing.Username = username;
|
||||
existing.EncryptedPassword = _protector.Protect(password);
|
||||
existing.ConnectedAt = DateTimeOffset.UtcNow;
|
||||
existing.LastSyncStatus = "connected";
|
||||
existing.LastSyncSource = "connect";
|
||||
existing.LastSyncMode = "connect";
|
||||
existing.LastSyncError = null;
|
||||
existing.LastSyncAttemptedAt = DateTimeOffset.UtcNow;
|
||||
existing.LastSyncSucceededAt = existing.LastSyncAttemptedAt;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return new ImapConnectResult(existing.Username);
|
||||
}
|
||||
|
||||
public Task<ImapConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
=> _db.ImapConnections.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
|
||||
public async Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (existing is null) return;
|
||||
_db.ImapConnections.Remove(existing);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ImapMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
|
||||
{
|
||||
maxResults = Math.Clamp(maxResults, 1, 25);
|
||||
try
|
||||
{
|
||||
using var client = await OpenInboxAsync(ownerUserId, false, cancellationToken);
|
||||
var searchQuery = string.IsNullOrWhiteSpace(query)
|
||||
? SearchQuery.All
|
||||
: SearchQuery.SubjectContains(query.Trim()).Or(SearchQuery.FromContains(query.Trim())).Or(SearchQuery.BodyContains(query.Trim()));
|
||||
|
||||
var uids = await client.Inbox.SearchAsync(searchQuery, cancellationToken);
|
||||
var window = uids.OrderByDescending(u => u.Id).Take(maxResults).ToList();
|
||||
var summaries = await FetchSummariesAsync(client, window, cancellationToken);
|
||||
|
||||
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
|
||||
return summaries;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ImapMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadKey, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(threadKey))
|
||||
{
|
||||
return Array.Empty<ImapMessageSummary>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = await OpenInboxAsync(ownerUserId, false, cancellationToken);
|
||||
var recentUids = (await client.Inbox.SearchAsync(SearchQuery.All, cancellationToken))
|
||||
.OrderByDescending(u => u.Id)
|
||||
.Take(ThreadScanWindow)
|
||||
.ToList();
|
||||
|
||||
var items = await client.Inbox.FetchAsync(recentUids, MessageSummaryItems.Envelope | MessageSummaryItems.References | MessageSummaryItems.UniqueId, cancellationToken);
|
||||
var matches = items.Where(item => ComputeThreadKey(item) == threadKey.Trim()).ToList();
|
||||
var summaries = matches.Select(ToSummary).OrderBy(s => s.Date).ToList();
|
||||
|
||||
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "reference-scan", true, null, cancellationToken);
|
||||
return summaries;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "reference-scan", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ImapMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = await OpenInboxAsync(ownerUserId, false, cancellationToken);
|
||||
var uid = ParseUid(messageId);
|
||||
|
||||
var summaryItems = await client.Inbox.FetchAsync(new[] { uid }, MessageSummaryItems.Envelope | MessageSummaryItems.References | MessageSummaryItems.UniqueId, cancellationToken);
|
||||
var summary = summaryItems.FirstOrDefault() ?? throw new InvalidOperationException($"IMAP message {messageId} was not found.");
|
||||
|
||||
var mime = await client.Inbox.GetMessageAsync(uid, cancellationToken);
|
||||
var bodyText = mime.TextBody ?? (mime.HtmlBody is null ? "" : StripHtml(mime.HtmlBody));
|
||||
var attachments = mime.Attachments.Select(a => new ImapMessageAttachment(
|
||||
a.ContentDisposition?.FileName ?? a.ContentType?.Name,
|
||||
a.ContentType?.MimeType,
|
||||
a is MimePart part ? part.Content?.Stream?.Length : null,
|
||||
a.ContentId,
|
||||
a.IsAttachment == false
|
||||
)).ToList();
|
||||
|
||||
await TouchSyncStateAsync(ownerUserId, "message-detail", "imap-message", true, null, cancellationToken);
|
||||
return new ImapMessageDetail(
|
||||
messageId,
|
||||
ComputeThreadKey(summary),
|
||||
summary.Envelope?.Subject ?? "",
|
||||
FormatAddresses(summary.Envelope?.From),
|
||||
FormatAddresses(summary.Envelope?.To),
|
||||
summary.Envelope?.Date,
|
||||
bodyText.Length > 200 ? bodyText[..200] : bodyText,
|
||||
bodyText.Trim(),
|
||||
mime.HtmlBody,
|
||||
Array.Empty<string>(),
|
||||
attachments);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await TouchSyncStateAsync(ownerUserId, "message-detail", "imap-message", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<ImapMessageSummary>> FetchSummariesAsync(ImapClient client, IList<UniqueId> uids, CancellationToken cancellationToken)
|
||||
{
|
||||
if (uids.Count == 0) return Array.Empty<ImapMessageSummary>();
|
||||
var items = await client.Inbox.FetchAsync(uids, MessageSummaryItems.Envelope | MessageSummaryItems.References | MessageSummaryItems.UniqueId, cancellationToken);
|
||||
return items.Select(ToSummary).ToList();
|
||||
}
|
||||
|
||||
private static ImapMessageSummary ToSummary(IMessageSummary item) => new(
|
||||
item.UniqueId.Id.ToString(),
|
||||
ComputeThreadKey(item),
|
||||
item.Envelope?.Subject ?? "",
|
||||
FormatAddresses(item.Envelope?.From),
|
||||
FormatAddresses(item.Envelope?.To),
|
||||
item.Envelope?.Date,
|
||||
"");
|
||||
|
||||
// The root Message-Id of the References chain, or this message's own Message-Id if it
|
||||
// starts no chain — a stand-in "thread id" that works without the IMAP THREAD extension.
|
||||
private static string ComputeThreadKey(IMessageSummary item)
|
||||
{
|
||||
if (item.References is { Count: > 0 })
|
||||
{
|
||||
return item.References[0];
|
||||
}
|
||||
return item.Envelope?.MessageId ?? item.UniqueId.Id.ToString();
|
||||
}
|
||||
|
||||
private static string FormatAddresses(InternetAddressList? list)
|
||||
=> list is null ? "" : string.Join(", ", list.Mailboxes.Select(m => m.Address));
|
||||
|
||||
private static string StripHtml(string html)
|
||||
=> System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ").Trim();
|
||||
|
||||
private static UniqueId ParseUid(string messageId)
|
||||
=> uint.TryParse(messageId, out var id) ? new UniqueId(id) : throw new InvalidOperationException($"Invalid IMAP message id: {messageId}");
|
||||
|
||||
private async Task<ImapClient> OpenInboxAsync(string ownerUserId, bool writable, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken)
|
||||
?? throw new InvalidOperationException("IMAP is not connected for this account.");
|
||||
|
||||
string password;
|
||||
try
|
||||
{
|
||||
password = _protector.Unprotect(connection.EncryptedPassword);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
throw new InvalidOperationException("Your stored IMAP connection can no longer be decrypted after a server key change. Disconnect and reconnect IMAP.");
|
||||
}
|
||||
|
||||
await EnsureHostIsExternalAsync(connection.Host, cancellationToken);
|
||||
var client = new ImapClient();
|
||||
await client.ConnectAsync(connection.Host, connection.Port, connection.UseSsl, cancellationToken);
|
||||
await client.AuthenticateAsync(connection.Username, password, cancellationToken);
|
||||
await client.Inbox.OpenAsync(writable ? FolderAccess.ReadWrite : FolderAccess.ReadOnly, cancellationToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
// SSRF guard: a user-supplied IMAP host resolves to an IP the server then opens a socket to.
|
||||
// Without this check an authenticated user could point "their mailbox" at loopback, RFC1918/
|
||||
// link-local ranges, or the cloud metadata address to probe internal infrastructure. Re-run on
|
||||
// every connect (not just the initial one) so a DNS record that resolves externally at connect
|
||||
// time can't be rebound internally for a later reconnect.
|
||||
private static async Task EnsureHostIsExternalAsync(string host, CancellationToken cancellationToken)
|
||||
{
|
||||
IPAddress[] addresses;
|
||||
try
|
||||
{
|
||||
addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
throw new InvalidOperationException("Could not resolve that IMAP host.");
|
||||
}
|
||||
|
||||
if (addresses.Length == 0 || addresses.Any(IsInternalAddress))
|
||||
{
|
||||
throw new InvalidOperationException("That IMAP host is not reachable.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInternalAddress(IPAddress address)
|
||||
{
|
||||
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
|
||||
|
||||
if (IPAddress.IsLoopback(address)) return true;
|
||||
if (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any)) return true;
|
||||
|
||||
if (address.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
var bytes = address.GetAddressBytes();
|
||||
if (bytes[0] == 10) return true; // 10.0.0.0/8
|
||||
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; // 172.16.0.0/12
|
||||
if (bytes[0] == 192 && bytes[1] == 168) return true; // 192.168.0.0/16
|
||||
if (bytes[0] == 169 && bytes[1] == 254) return true; // 169.254.0.0/16 (incl. cloud metadata)
|
||||
if (bytes[0] == 127) return true; // 127.0.0.0/8
|
||||
return false;
|
||||
}
|
||||
|
||||
if (address.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
{
|
||||
if (address.IsIPv6LinkLocal || address.IsIPv6SiteLocal) return true;
|
||||
var bytes = address.GetAddressBytes();
|
||||
if ((bytes[0] & 0xFE) == 0xFC) return true; // fc00::/7 (unique local)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true; // unknown address family: fail closed
|
||||
}
|
||||
|
||||
private async Task TouchSyncStateAsync(string ownerUserId, string mode, string source, bool succeeded, string? error, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (connection is null) return;
|
||||
|
||||
connection.LastSyncAttemptedAt = DateTimeOffset.UtcNow;
|
||||
connection.LastSyncMode = mode;
|
||||
connection.LastSyncSource = source;
|
||||
connection.LastSyncStatus = succeeded ? "ok" : "error";
|
||||
connection.LastSyncError = succeeded ? null : error;
|
||||
if (succeeded)
|
||||
{
|
||||
connection.LastSyncedAt = DateTimeOffset.UtcNow;
|
||||
connection.LastSyncSucceededAt = connection.LastSyncedAt;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -386,6 +386,31 @@ public static class StartupInitializationExtensions
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress" ON "MicrosoftGraphConnections" ("OwnerUserId", "MailAddress");""");
|
||||
}
|
||||
|
||||
static void EnsureImapConnectionsTable(DbConnection c)
|
||||
{
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "ImapConnections" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_ImapConnections" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"Host" TEXT NOT NULL,
|
||||
"Port" INTEGER NOT NULL,
|
||||
"UseSsl" INTEGER NOT NULL,
|
||||
"Username" TEXT NOT NULL,
|
||||
"EncryptedPassword" TEXT NOT NULL,
|
||||
"ConnectedAt" TEXT NOT NULL,
|
||||
"LastSyncedAt" TEXT NULL,
|
||||
"LastSyncAttemptedAt" TEXT NULL,
|
||||
"LastSyncSucceededAt" TEXT NULL,
|
||||
"LastSyncMode" TEXT NULL,
|
||||
"LastSyncSource" TEXT NULL,
|
||||
"LastSyncStatus" TEXT NULL,
|
||||
"LastSyncError" TEXT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_ImapConnections_OwnerUserId" ON "ImapConnections" ("OwnerUserId");""");
|
||||
}
|
||||
|
||||
static void EnsureCvTables(DbConnection c)
|
||||
{
|
||||
Exec(c, """
|
||||
@@ -455,6 +480,7 @@ public static class StartupInitializationExtensions
|
||||
|
||||
EnsureGmailConnectionsTable(conn);
|
||||
EnsureMicrosoftGraphConnectionsTable(conn);
|
||||
EnsureImapConnectionsTable(conn);
|
||||
EnsureCvTables(conn);
|
||||
|
||||
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
||||
@@ -634,6 +660,7 @@ public static class StartupInitializationExtensions
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "JobEvents", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "GmailConnections", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "MicrosoftGraphConnections", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "ImapConnections", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id");
|
||||
@@ -851,6 +878,30 @@ public static class StartupInitializationExtensions
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "ImapConnections"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `ImapConnections` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`Host` varchar(255) NOT NULL,
|
||||
`Port` int NOT NULL,
|
||||
`UseSsl` tinyint(1) NOT NULL,
|
||||
`Username` varchar(255) NOT NULL,
|
||||
`EncryptedPassword` longtext NOT NULL,
|
||||
`ConnectedAt` datetime(6) NOT NULL,
|
||||
`LastSyncedAt` datetime(6) NULL,
|
||||
`LastSyncAttemptedAt` datetime(6) NULL,
|
||||
`LastSyncSucceededAt` datetime(6) NULL,
|
||||
`LastSyncMode` varchar(255) NULL,
|
||||
`LastSyncSource` varchar(255) NULL,
|
||||
`LastSyncStatus` varchar(255) NULL,
|
||||
`LastSyncError` longtext NULL,
|
||||
PRIMARY KEY (`Id`)
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "TailoredCvDrafts"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
@@ -956,6 +1007,13 @@ public static class StartupInitializationExtensions
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "ImapConnections", "IX_ImapConnections_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_ImapConnections_OwnerUserId` ON `ImapConnections` (`OwnerUserId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
|
||||
@@ -29,5 +29,6 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public sealed class ImapConnection
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OwnerUserId { get; set; } = "";
|
||||
public string Host { get; set; } = "";
|
||||
public int Port { get; set; } = 993;
|
||||
public bool UseSsl { get; set; } = true;
|
||||
public string Username { get; set; } = "";
|
||||
public string EncryptedPassword { get; set; } = "";
|
||||
public DateTimeOffset ConnectedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? LastSyncedAt { get; set; }
|
||||
public DateTimeOffset? LastSyncAttemptedAt { get; set; }
|
||||
public DateTimeOffset? LastSyncSucceededAt { get; set; }
|
||||
public string? LastSyncMode { get; set; }
|
||||
public string? LastSyncSource { get; set; }
|
||||
public string? LastSyncStatus { get; set; }
|
||||
public string? LastSyncError { get; set; }
|
||||
}
|
||||
@@ -203,7 +203,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
}, []);
|
||||
|
||||
if (requireAuth === null || !authResolved) return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
|
||||
if (requireAuth && !me) return <Navigate to="/login" replace state={{ from: path }} />;
|
||||
if (requireAuth && !me) return <Navigate to="/" replace state={{ from: path }} />;
|
||||
|
||||
const pageTitle = titleFor(path, t);
|
||||
const breadcrumbs = breadcrumbsFor(path, t);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { api } from "./api";
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function LoginStub() {
|
||||
const location = useLocation() as { state?: { from?: string } };
|
||||
return <div>login page, from={location.state?.from ?? "none"}</div>;
|
||||
}
|
||||
|
||||
// Regression check for the auth-guard fix: a protected route bounces an
|
||||
// unauthenticated visitor to "/" with `state.from` set to the page they
|
||||
// wanted. The home page must forward that state to /login so sign-in
|
||||
// returns them to the originally-requested page instead of dropping them
|
||||
// on /jobs.
|
||||
describe("LandingPage forwards deep-link intent to /login", () => {
|
||||
it("preserves location.state.from through the Sign in CTA", async () => {
|
||||
mockedApi.get.mockRejectedValueOnce(new Error("401")); // /auth/me: not signed in
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[{ pathname: "/", state: { from: "/jobs/42" } }]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LoginStub />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/auth/me"));
|
||||
|
||||
const signInButtons = await screen.findAllByText("Sign in", { selector: "button" });
|
||||
await userEvent.click(signInButtons[0]);
|
||||
|
||||
expect(await screen.findByText("login page, from=/jobs/42")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
|
||||
|
||||
import { Box, Button, Container, Stack, Typography } from "@mui/material";
|
||||
import { alpha } from "@mui/material/styles";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import DashboardIcon from "@mui/icons-material/SpaceDashboardOutlined";
|
||||
import AlarmIcon from "@mui/icons-material/NotificationsActiveOutlined";
|
||||
@@ -42,6 +42,7 @@ const PRICING: { name: string; price: string; cadence: string; highlight: boolea
|
||||
|
||||
export default function LandingPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation() as { state?: { from?: string } };
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
// If the visitor already has a session, send them straight into the app.
|
||||
@@ -54,6 +55,11 @@ export default function LandingPage() {
|
||||
return () => { active = false; };
|
||||
}, [navigate]);
|
||||
|
||||
// A protected route redirects unauthenticated visitors here with the page they
|
||||
// wanted in location state; forward it to /login so sign-in returns them there
|
||||
// instead of dropping them on /jobs.
|
||||
const goToLogin = () => navigate("/login", { state: location.state });
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<Box sx={{ minHeight: "100vh", display: "grid", placeItems: "center", bgcolor: BRAND_DARK }}>
|
||||
@@ -78,7 +84,7 @@ export default function LandingPage() {
|
||||
<Box sx={{ width: 30, height: 30, borderRadius: "8px", background: "linear-gradient(135deg,#6366f1,#22d3ee)", display: "grid", placeItems: "center", color: BRAND_DARK, fontWeight: 900 }}>✓</Box>
|
||||
<Typography sx={{ color: "#fff", fontWeight: 800, fontSize: 20 }}>JobTrack</Typography>
|
||||
</Stack>
|
||||
<Button variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
|
||||
<Button variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
|
||||
Sign in
|
||||
</Button>
|
||||
</Stack>
|
||||
@@ -100,7 +106,7 @@ export default function LandingPage() {
|
||||
right job — all in one focused workspace. Assistive, never autonomous: you approve every draft.
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<Button size="large" variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}>
|
||||
<Button size="large" variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}>
|
||||
Get started
|
||||
</Button>
|
||||
<Button size="large" variant="outlined" href="#features" sx={{ color: "#e2e8f0", borderColor: alpha("#ffffff", 0.25), px: 3, py: 1.25 }}>
|
||||
@@ -217,7 +223,7 @@ export default function LandingPage() {
|
||||
<Button
|
||||
fullWidth
|
||||
variant={tier.highlight ? "contained" : "outlined"}
|
||||
onClick={() => navigate("/login")}
|
||||
onClick={goToLogin}
|
||||
sx={tier.highlight ? { background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800 } : { fontWeight: 700 }}
|
||||
>
|
||||
{tier.cta}
|
||||
@@ -237,7 +243,7 @@ export default function LandingPage() {
|
||||
<Typography sx={{ fontWeight: 800, fontSize: { xs: 24, md: 30 }, mb: 1 }}>Ready to organize your search?</Typography>
|
||||
<Typography sx={{ color: "#94a3b8", fontSize: 17 }}>Sign in to start tracking applications, tailoring CVs, and following up with intent.</Typography>
|
||||
</Box>
|
||||
<Button size="large" variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}>
|
||||
<Button size="large" variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}>
|
||||
Sign in →
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -248,7 +254,7 @@ export default function LandingPage() {
|
||||
<Container maxWidth="lg">
|
||||
<Stack direction={{ xs: "column", sm: "row" }} justifyContent="space-between" alignItems="center" spacing={1}>
|
||||
<Typography sx={{ color: "text.secondary", fontSize: 14 }}>© {new Date().getFullYear()} JobTrack — a focused workspace for the modern job search.</Typography>
|
||||
<Button variant="text" onClick={() => navigate("/login")} sx={{ fontWeight: 700 }}>Sign in</Button>
|
||||
<Button variant="text" onClick={goToLogin} sx={{ fontWeight: 700 }}>Sign in</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user