Add OAth flow for Gmail and update tables and UI

This commit is contained in:
cesnimda
2026-03-21 14:02:19 +01:00
parent 51a539068f
commit ed68e44eaf
17 changed files with 1180 additions and 53 deletions
+3
View File
@@ -5,6 +5,9 @@ AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET
AUTH_ADMIN_EMAIL=admin@example.com
AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
AUTH_GOOGLE_CLIENT_ID=723556162227-llqucvpog2esn1dutmtvuul1lv374or6.apps.googleusercontent.com
GOOGLE_GMAIL_CLIENT_SECRET=CHANGE_ME_GOOGLE_OAUTH_CLIENT_SECRET
# Optional. If omitted, the backend uses https://<your-domain>/api/gmail/oauth/callback
GOOGLE_GMAIL_REDIRECT_URI=
# Optional: only needed if you want the UI to call a non-default API base URL.
# In production the UI defaults to `/api`.
+11
View File
@@ -16,6 +16,7 @@ namespace JobTrackerApi.Data
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<Attachment> Attachments => Set<Attachment>();
public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>();
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
@@ -58,6 +59,16 @@ namespace JobTrackerApi.Data
.HasForeignKey(c => c.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<GmailConnection>()
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
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)
@@ -20,6 +20,29 @@ namespace JobTrackerApi.Controllers
private string? CurrentUserId =>
User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? User?.FindFirstValue("sub");
private static string? NormalizeSource(string? source)
{
if (string.IsNullOrWhiteSpace(source)) return null;
var value = source.Trim();
if (Uri.TryCreate(value, UriKind.Absolute, out var uri) && !string.IsNullOrWhiteSpace(uri.Host))
{
value = uri.Host;
}
value = value.Replace("www.", "", StringComparison.OrdinalIgnoreCase).Trim().Trim('/');
var lower = value.ToLowerInvariant();
return lower switch
{
"linkedin" or "linkedin.com" => "LinkedIn",
"finn" or "finn.no" => "Finn",
"nav" or "nav.no" => "NAV",
"jobbnorge" or "jobbnorge.no" => "Jobbnorge",
_ => string.Join(" ", value.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries).Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant()))
};
}
[HttpGet]
public async Task<ActionResult<List<Company>>> GetAll(CancellationToken cancellationToken)
{
@@ -86,7 +109,7 @@ namespace JobTrackerApi.Controllers
OwnerUserId = string.IsNullOrWhiteSpace(userId) ? null : userId,
Name = name,
Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(),
Source = string.IsNullOrWhiteSpace(request.Source) ? null : request.Source.Trim(),
Source = NormalizeSource(request.Source),
};
_db.Companies.Add(company);
@@ -111,7 +134,7 @@ namespace JobTrackerApi.Controllers
company.Name = name;
company.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim();
company.Source = string.IsNullOrWhiteSpace(request.Source) ? null : request.Source.Trim();
company.Source = NormalizeSource(request.Source);
company.RecruiterName = string.IsNullOrWhiteSpace(request.RecruiterName) ? null : request.RecruiterName.Trim();
company.RecruiterEmail = string.IsNullOrWhiteSpace(request.RecruiterEmail) ? null : request.RecruiterEmail.Trim();
@@ -0,0 +1,187 @@
using System.Security.Claims;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/gmail")]
[Authorize]
public sealed class GmailController : ControllerBase
{
private readonly IGmailOAuthService _gmail;
private readonly JobTrackerContext _db;
private readonly IConfiguration _cfg;
public GmailController(IGmailOAuthService gmail, JobTrackerContext db, IConfiguration cfg)
{
_gmail = gmail;
_db = db;
_cfg = cfg;
}
[HttpGet("status")]
public async Task<IActionResult> Status(CancellationToken cancellationToken)
{
var ownerUserId = GetRequiredOwnerUserId();
var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
return Ok(new
{
connected = connection is not null,
gmailAddress = connection?.GmailAddress,
connectedAt = connection?.ConnectedAt,
lastSyncedAt = connection?.LastSyncedAt,
});
}
[HttpGet("connect-url")]
public IActionResult ConnectUrl()
{
var ownerUserId = GetRequiredOwnerUserId();
var url = _gmail.BuildAuthorizationUrl(ownerUserId, GetRedirectUri());
return Ok(new { url });
}
[AllowAnonymous]
[HttpGet("oauth/callback")]
public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(error))
{
return Content(BuildPopupHtml(false, $"Google returned an error: {error}"), "text/html");
}
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state))
{
return Content(BuildPopupHtml(false, "Missing Google OAuth code or state."), "text/html");
}
var ownerUserId = _gmail.ConsumeState(state);
if (string.IsNullOrWhiteSpace(ownerUserId))
{
return Content(BuildPopupHtml(false, "This Gmail connection request is no longer valid. Start the connection again."), "text/html");
}
try
{
var result = await _gmail.ExchangeCodeAsync(ownerUserId, code, GetRedirectUri(), cancellationToken);
return Content(BuildPopupHtml(true, $"Connected Gmail: {result.GmailAddress}"), "text/html");
}
catch (Exception ex)
{
return Content(BuildPopupHtml(false, ex.Message), "text/html");
}
}
[HttpDelete("connection")]
public async Task<IActionResult> Disconnect(CancellationToken cancellationToken)
{
var ownerUserId = GetRequiredOwnerUserId();
await _gmail.DisconnectAsync(ownerUserId, cancellationToken);
return NoContent();
}
[HttpGet("messages")]
public async Task<IActionResult> Messages([FromQuery] string? query, [FromQuery] int maxResults = 12, CancellationToken cancellationToken = default)
{
var ownerUserId = GetRequiredOwnerUserId();
var items = await _gmail.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken);
return Ok(items);
}
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
[HttpPost("import")]
public async Task<IActionResult> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken)
{
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
if (string.IsNullOrWhiteSpace(request.MessageId)) return BadRequest("MessageId is required.");
var ownerUserId = GetRequiredOwnerUserId();
var job = await _db.JobApplications.Include(x => x.Company).FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
if (job is null) return NotFound("Job application not found.");
var existing = await _db.Correspondences.FirstOrDefaultAsync(
x => x.JobApplicationId == request.JobApplicationId && x.ExternalMessageId == request.MessageId,
cancellationToken);
if (existing is not null)
{
return Ok(existing);
}
var detail = await _gmail.GetMessageAsync(ownerUserId, request.MessageId, cancellationToken);
var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
var gmailAddress = me?.GmailAddress ?? string.Empty;
var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase);
var message = new Correspondence
{
JobApplicationId = request.JobApplicationId,
From = isMe ? "Me" : "Company",
Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(),
Channel = "Email",
ExternalMessageId = detail.Id,
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
Date = detail.Date?.LocalDateTime ?? DateTime.Now,
};
_db.Correspondences.Add(message);
if (job.Company is not null)
{
job.Company.LastContactedAt = DateTime.UtcNow;
}
await _db.SaveChangesAsync(cancellationToken);
return Ok(message);
}
private string GetRequiredOwnerUserId()
{
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
?? throw new InvalidOperationException("Authenticated user id is missing.");
}
private string GetRedirectUri()
{
var configured = (_cfg["Google:GmailRedirectUri"] ?? _cfg["Google:RedirectUri"] ?? "").Trim();
if (!string.IsNullOrWhiteSpace(configured)) return configured;
var publicBaseUrl = (_cfg["App:PublicBaseUrl"] ?? "").Trim().TrimEnd('/');
if (!string.IsNullOrWhiteSpace(publicBaseUrl))
{
return $"{publicBaseUrl}/api/gmail/oauth/callback";
}
return $"{Request.Scheme}://{Request.Host}/api/gmail/oauth/callback";
}
private static string BuildPopupHtml(bool success, string message)
{
var escaped = System.Net.WebUtility.HtmlEncode(message);
var status = success ? "connected" : "error";
var title = success ? "Gmail connected" : "Gmail connection failed";
var serializedMessage = System.Text.Json.JsonSerializer.Serialize(message);
return $@"<!doctype html>
<html>
<head>
<meta charset=""utf-8"" />
<title>Gmail connection</title>
</head>
<body style=""font-family:Segoe UI,Arial,sans-serif;padding:24px;line-height:1.5;"">
<h2>{title}</h2>
<p>{escaped}</p>
<p>You can close this window.</p>
<script>
if (window.opener) {{
window.opener.postMessage({{ source: 'jobtracker-gmail-oauth', status: '{status}', message: {serializedMessage} }}, '*');
}}
window.close();
</script>
</body>
</html>";
}
}
@@ -14,11 +14,13 @@ namespace JobTrackerApi.Controllers
{
private readonly JobTrackerContext _db;
private readonly ISummarizerService _summarizer;
private readonly IAppEmailSender _email;
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer)
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email)
{
_db = db;
_summarizer = summarizer;
_email = email;
}
private string? CurrentUserId =>
@@ -68,6 +70,30 @@ namespace JobTrackerApi.Controllers
return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
}
private static string? NormalizeTags(string? raw)
{
var normalized = SplitTags(raw)
.Select(tag => tag.Trim())
.Where(tag => tag.Length > 0)
.GroupBy(tag => tag, StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var first = group.First();
return string.Join(" ", first.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant()));
})
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
.ToList();
return normalized.Count == 0 ? null : JsonSerializer.Serialize(normalized);
}
private static string? NormalizeUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url)) return null;
var value = url.Trim();
return Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri.ToString() : value;
}
public sealed record PagedResult<T>(List<T> Items, int Total, int Page, int PageSize);
@@ -538,10 +564,10 @@ namespace JobTrackerApi.Controllers
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description,
TranslatedDescription = string.IsNullOrWhiteSpace(request.TranslatedDescription) ? null : request.TranslatedDescription,
DescriptionLanguage = string.IsNullOrWhiteSpace(request.DescriptionLanguage) ? null : request.DescriptionLanguage.Trim(),
Tags = string.IsNullOrWhiteSpace(request.Tags) ? null : request.Tags,
Tags = NormalizeTags(request.Tags),
Deadline = request.Deadline,
CoverLetterText = string.IsNullOrWhiteSpace(request.CoverLetterText) ? null : request.CoverLetterText,
JobUrl = string.IsNullOrWhiteSpace(request.JobUrl) ? null : request.JobUrl,
JobUrl = NormalizeUrl(request.JobUrl),
DateApplied = request.DateApplied ?? DateTime.Now,
ResponseReceived = false,
ResponseDate = null,
@@ -635,10 +661,10 @@ namespace JobTrackerApi.Controllers
job.Description = request.Description;
job.TranslatedDescription = request.TranslatedDescription;
job.DescriptionLanguage = request.DescriptionLanguage;
job.Tags = request.Tags;
job.Tags = NormalizeTags(request.Tags);
job.Deadline = request.Deadline;
job.CoverLetterText = request.CoverLetterText;
job.JobUrl = request.JobUrl;
job.JobUrl = NormalizeUrl(request.JobUrl);
if (request.DateApplied is not null) job.DateApplied = request.DateApplied.Value;
if (oldResponseReceived != job.ResponseReceived || oldResponseDate != job.ResponseDate)
@@ -1076,6 +1102,7 @@ namespace JobTrackerApi.Controllers
public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason);
public sealed record DuplicateCheckResult(bool HasDuplicates, List<DuplicateCandidateDto> Matches);
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn);
public sealed record SendFollowUpRequest(string? ToEmail, string Subject, string Body, DateTime? NextFollowUpAt);
public sealed record TagTrendResponse(List<string> Months, List<TagTrendSeries> Series);
[HttpGet("analytics-overview")]
@@ -1310,6 +1337,50 @@ namespace JobTrackerApi.Controllers
return Ok(new FollowUpDraftDto(subject, body, reason, DateTime.Today));
}
[HttpPost("{id:int}/send-followup")]
public async Task<IActionResult> SendFollowUp([FromRoute] int id, [FromBody] SendFollowUpRequest request, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
if (string.IsNullOrWhiteSpace(request.Subject)) return BadRequest("Subject is required.");
if (string.IsNullOrWhiteSpace(request.Body)) return BadRequest("Body is required.");
var toEmail = (request.ToEmail ?? job.Company?.RecruiterEmail ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(toEmail)) return BadRequest("Recipient email is required.");
await _email.SendAsync(toEmail, request.Subject.Trim(), request.Body.Trim(), cancellationToken);
_db.Correspondences.Add(new Correspondence
{
JobApplicationId = id,
From = "Me",
Subject = request.Subject.Trim(),
Channel = "Email",
Content = request.Body.Trim(),
Date = DateTime.Now,
});
if (job.Company is not null)
{
job.Company.LastContactedAt = DateTime.Now;
if (request.NextFollowUpAt is not null)
{
job.Company.NextContactAt = request.NextFollowUpAt.Value;
}
}
if (request.NextFollowUpAt is not null)
{
job.FollowUpAt = request.NextFollowUpAt.Value;
}
await _db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("summarizer-metrics")]
public async Task<ActionResult<SummarizerMetrics>> GetSummarizerMetrics(CancellationToken cancellationToken)
{
+26
View File
@@ -77,6 +77,7 @@ builder.Services.AddControllers();
builder.Services.AddDataProtection();
builder.Services.AddHostedService<RulesHostedService>();
builder.Services.AddHostedService<DailyExportHostedService>();
builder.Services.AddHostedService<JobEnrichmentHostedService>();
builder.Services.AddHttpClient("jobimport")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
@@ -94,6 +95,7 @@ builder.Services.AddHttpClient("summarizer", client =>
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
builder.Services.AddIdentityCore<ApplicationUser>(options =>
{
@@ -449,6 +451,28 @@ CREATE TABLE IF NOT EXISTS "UserRuleSettings" (
EnsureUserRuleSettingsTable(conn);
static void EnsureGmailConnectionsTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "GmailConnections" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_GmailConnections" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"GmailAddress" TEXT NOT NULL,
"EncryptedRefreshToken" TEXT NOT NULL,
"EncryptedAccessToken" TEXT NULL,
"AccessTokenExpiresAt" TEXT NULL,
"Scope" TEXT NOT NULL,
"ConnectedAt" TEXT NOT NULL,
"LastSyncedAt" TEXT NULL
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId" ON "GmailConnections" ("OwnerUserId");""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");""");
}
EnsureGmailConnectionsTable(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
// and at least one of the new columns already exists.
var isLegacy =
@@ -478,6 +502,7 @@ CREATE TABLE IF NOT EXISTS "UserRuleSettings" (
{
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
}
// Record the migration as applied.
@@ -495,6 +520,7 @@ CREATE TABLE IF NOT EXISTS "UserRuleSettings" (
// Ensure ownership columns exist even on non-legacy DBs.
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
// Ensure data folder exists before creating/opening SQLite files.
Directory.CreateDirectory(paths.DataRoot);
+417
View File
@@ -0,0 +1,417 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace JobTrackerApi.Services;
public interface IGmailOAuthService
{
string BuildAuthorizationUrl(string ownerUserId, string redirectUri);
string? ConsumeState(string state);
Task<GmailOAuthExchangeResult> ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken);
Task<GmailConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
}
public sealed record GmailOAuthExchangeResult(string GmailAddress);
public sealed record GmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
public sealed record GmailMessageDetail(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml);
internal sealed class GmailTokenResponse
{
public string? access_token { get; set; }
public int expires_in { get; set; }
public string? refresh_token { get; set; }
public string? scope { get; set; }
public string? token_type { get; set; }
}
public sealed class GmailOAuthService : IGmailOAuthService
{
private const string Scope = "openid email profile https://www.googleapis.com/auth/gmail.readonly";
private readonly IConfiguration _cfg;
private readonly JobTrackerContext _db;
private readonly IDataProtector _protector;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
public GmailOAuthService(
IConfiguration cfg,
JobTrackerContext db,
IDataProtectionProvider protectionProvider,
IHttpClientFactory httpClientFactory,
IMemoryCache cache)
{
_cfg = cfg;
_db = db;
_protector = protectionProvider.CreateProtector("gmail-oauth-tokens-v1");
_httpClientFactory = httpClientFactory;
_cache = cache;
}
public string BuildAuthorizationUrl(string ownerUserId, string redirectUri)
{
var clientId = GetRequiredClientId();
var state = Convert.ToBase64String(Guid.NewGuid().ToByteArray())
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
_cache.Set(GetStateCacheKey(state), ownerUserId, TimeSpan.FromMinutes(15));
var query = new Dictionary<string, string?>
{
["client_id"] = clientId,
["redirect_uri"] = redirectUri,
["response_type"] = "code",
["access_type"] = "offline",
["prompt"] = "consent",
["include_granted_scopes"] = "true",
["scope"] = Scope,
["state"] = state,
};
var encoded = string.Join("&", query.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value ?? "")}"));
return $"https://accounts.google.com/o/oauth2/v2/auth?{encoded}";
}
public async Task<GmailOAuthExchangeResult> ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken)
{
var tokens = await ExchangeCodeForTokensAsync(code, redirectUri, cancellationToken);
var accessToken = tokens.access_token?.Trim();
var refreshToken = tokens.refresh_token?.Trim();
if (string.IsNullOrWhiteSpace(accessToken))
throw new InvalidOperationException("Google did not return an access token.");
if (string.IsNullOrWhiteSpace(refreshToken))
throw new InvalidOperationException("Google did not return a refresh token. Reconnect Gmail and ensure consent is granted.");
var gmailAddress = await GetProfileAsync(accessToken, cancellationToken);
if (string.IsNullOrWhiteSpace(gmailAddress))
throw new InvalidOperationException("Google did not return a Gmail address.");
var existing = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (existing is null)
{
existing = new GmailConnection
{
OwnerUserId = ownerUserId,
};
_db.GmailConnections.Add(existing);
}
existing.GmailAddress = gmailAddress.Trim();
existing.EncryptedRefreshToken = _protector.Protect(refreshToken);
existing.EncryptedAccessToken = _protector.Protect(accessToken);
existing.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(tokens.expires_in - 60, 60));
existing.Scope = tokens.scope?.Trim() ?? Scope;
existing.ConnectedAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return new GmailOAuthExchangeResult(existing.GmailAddress);
}
public string? ConsumeState(string state)
{
if (!_cache.TryGetValue<string>(GetStateCacheKey(state), out var ownerUserId))
{
return null;
}
_cache.Remove(GetStateCacheKey(state));
return ownerUserId;
}
public Task<GmailConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
{
return _db.GmailConnections.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
}
public async Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken)
{
var existing = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (existing is null) return;
_db.GmailConnections.Remove(existing);
await _db.SaveChangesAsync(cancellationToken);
}
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
{
maxResults = Math.Clamp(maxResults, 1, 25);
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults={maxResults}";
if (!string.IsNullOrWhiteSpace(query))
{
url += $"&q={Uri.EscapeDataString(query.Trim())}";
}
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
{
return Array.Empty<GmailMessageSummary>();
}
var ids = messagesElement.EnumerateArray()
.Select(x => x.TryGetProperty("id", out var id) ? id.GetString() : null)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Cast<string>()
.ToList();
var results = new List<GmailMessageSummary>(ids.Count);
foreach (var id in ids)
{
var detail = await GetMessageAsync(ownerUserId, id, cancellationToken);
results.Add(new GmailMessageSummary(detail.Id, detail.ThreadId, detail.Subject, detail.From, detail.To, detail.Date, detail.Snippet));
}
await TouchSyncTimeAsync(ownerUserId, cancellationToken);
return results;
}
public async Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
{
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages/{Uri.EscapeDataString(messageId)}?format=full";
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
var root = doc.RootElement;
var threadId = root.TryGetProperty("threadId", out var threadEl) ? threadEl.GetString() ?? "" : "";
var snippet = root.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? "" : "";
var payload = root.GetProperty("payload");
var headers = ReadHeaders(payload);
var bodyText = ExtractBody(payload, "text/plain");
var bodyHtml = ExtractBody(payload, "text/html");
if (string.IsNullOrWhiteSpace(bodyText) && !string.IsNullOrWhiteSpace(bodyHtml))
{
bodyText = StripHtml(bodyHtml);
}
return new GmailMessageDetail(
messageId,
threadId,
headers.TryGetValue("subject", out var subject) ? subject : "",
headers.TryGetValue("from", out var from) ? from : "",
headers.TryGetValue("to", out var to) ? to : "",
headers.TryGetValue("date", out var dateRaw) && DateTimeOffset.TryParse(dateRaw, out var parsedDate) ? parsedDate : null,
snippet,
bodyText.Trim(),
string.IsNullOrWhiteSpace(bodyHtml) ? null : bodyHtml
);
}
private async Task<string> GetValidAccessTokenAsync(string ownerUserId, CancellationToken cancellationToken)
{
var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (connection is null)
throw new InvalidOperationException("Gmail is not connected for this account.");
if (!string.IsNullOrWhiteSpace(connection.EncryptedAccessToken) &&
connection.AccessTokenExpiresAt is { } expiresAt &&
expiresAt > DateTimeOffset.UtcNow.AddMinutes(1))
{
return _protector.Unprotect(connection.EncryptedAccessToken);
}
var refreshToken = _protector.Unprotect(connection.EncryptedRefreshToken);
var refreshed = await RefreshAccessTokenAsync(refreshToken, cancellationToken);
var accessToken = refreshed.access_token?.Trim();
if (string.IsNullOrWhiteSpace(accessToken))
throw new InvalidOperationException("Failed to refresh Gmail access token.");
connection.EncryptedAccessToken = _protector.Protect(accessToken);
connection.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(refreshed.expires_in - 60, 60));
if (!string.IsNullOrWhiteSpace(refreshed.scope))
{
connection.Scope = refreshed.scope.Trim();
}
await _db.SaveChangesAsync(cancellationToken);
return accessToken;
}
private async Task<GmailTokenResponse> ExchangeCodeForTokensAsync(string code, string redirectUri, CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient();
using var response = await client.PostAsync(
"https://oauth2.googleapis.com/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["code"] = code,
["client_id"] = GetRequiredClientId(),
["client_secret"] = GetRequiredClientSecret(),
["redirect_uri"] = redirectUri,
["grant_type"] = "authorization_code",
}),
cancellationToken);
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Google token exchange failed: {payload}");
}
return JsonSerializer.Deserialize<GmailTokenResponse>(payload)
?? throw new InvalidOperationException("Unable to parse Google token response.");
}
private async Task<GmailTokenResponse> RefreshAccessTokenAsync(string refreshToken, CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient();
using var response = await client.PostAsync(
"https://oauth2.googleapis.com/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["refresh_token"] = refreshToken,
["client_id"] = GetRequiredClientId(),
["client_secret"] = GetRequiredClientSecret(),
["grant_type"] = "refresh_token",
}),
cancellationToken);
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Google token refresh failed: {payload}");
}
return JsonSerializer.Deserialize<GmailTokenResponse>(payload)
?? throw new InvalidOperationException("Unable to parse Google refresh response.");
}
private async Task<string> GetProfileAsync(string accessToken, CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var response = await client.GetAsync("https://gmail.googleapis.com/gmail/v1/users/me/profile", cancellationToken);
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Failed to read Gmail profile: {payload}");
}
using var doc = JsonDocument.Parse(payload);
return doc.RootElement.TryGetProperty("emailAddress", out var emailEl)
? emailEl.GetString() ?? ""
: "";
}
private async Task TouchSyncTimeAsync(string ownerUserId, CancellationToken cancellationToken)
{
var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (connection is null) return;
connection.LastSyncedAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
}
private string GetRequiredClientId()
{
return (_cfg["Google:ClientId"] ?? _cfg["Auth:GoogleClientId"] ?? "").Trim() switch
{
"" => throw new InvalidOperationException("Google Gmail client ID is not configured."),
var value => value,
};
}
private string GetRequiredClientSecret()
{
return (_cfg["Google:GmailClientSecret"] ?? _cfg["Google:ClientSecret"] ?? "").Trim() switch
{
"" => throw new InvalidOperationException("Google Gmail client secret is not configured."),
var value => value,
};
}
private static string GetStateCacheKey(string state) => $"gmail-oauth-state:{state}";
private static Dictionary<string, string> ReadHeaders(JsonElement payload)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!payload.TryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array)
{
return result;
}
foreach (var item in headers.EnumerateArray())
{
var name = item.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null;
var value = item.TryGetProperty("value", out var valueEl) ? valueEl.GetString() : null;
if (string.IsNullOrWhiteSpace(name) || value is null) continue;
result[name.Trim()] = value;
}
return result;
}
private static string ExtractBody(JsonElement payload, string mimeType)
{
if (payload.TryGetProperty("mimeType", out var mimeTypeEl) &&
string.Equals(mimeTypeEl.GetString(), mimeType, StringComparison.OrdinalIgnoreCase) &&
payload.TryGetProperty("body", out var bodyEl) &&
bodyEl.TryGetProperty("data", out var dataEl))
{
return DecodeBody(dataEl.GetString());
}
if (payload.TryGetProperty("parts", out var partsEl) && partsEl.ValueKind == JsonValueKind.Array)
{
foreach (var part in partsEl.EnumerateArray())
{
var nested = ExtractBody(part, mimeType);
if (!string.IsNullOrWhiteSpace(nested))
{
return nested;
}
}
}
return "";
}
private static string DecodeBody(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return "";
var normalized = raw.Replace('-', '+').Replace('_', '/');
var padding = normalized.Length % 4;
if (padding > 0)
{
normalized = normalized.PadRight(normalized.Length + (4 - padding), '=');
}
try
{
var bytes = Convert.FromBase64String(normalized);
return Encoding.UTF8.GetString(bytes);
}
catch
{
return "";
}
}
private static string StripHtml(string html)
{
if (string.IsNullOrWhiteSpace(html)) return "";
return System.Text.RegularExpressions.Regex.Replace(html, "<.*?>", string.Empty)
.Replace("&nbsp;", " ")
.Replace("&amp;", "&")
.Trim();
}
}
@@ -0,0 +1,89 @@
using System.Text.Json;
using JobTrackerApi.Data;
using JobTrackerApi.Services.JobImport;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
public sealed class JobEnrichmentHostedService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<JobEnrichmentHostedService> _logger;
public JobEnrichmentHostedService(IServiceProvider services, ILogger<JobEnrichmentHostedService> logger)
{
_services = services;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var summarizer = scope.ServiceProvider.GetRequiredService<ISummarizerService>();
var jobs = await db.JobApplications
.Where(j => !j.IsDeleted)
.Where(j => string.IsNullOrWhiteSpace(j.ShortSummary) || string.IsNullOrWhiteSpace(j.Tags))
.OrderByDescending(j => j.DateApplied)
.Take(20)
.ToListAsync(stoppingToken);
var changed = 0;
foreach (var job in jobs)
{
var sourceText = string.IsNullOrWhiteSpace(job.Description) ? job.Notes : job.Description;
if (string.IsNullOrWhiteSpace(job.Tags) && !string.IsNullOrWhiteSpace(sourceText))
{
var tags = SkillTagger.Detect(sourceText)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
if (tags.Count > 0)
{
job.Tags = JsonSerializer.Serialize(tags);
changed++;
}
}
if (string.IsNullOrWhiteSpace(job.ShortSummary) && !string.IsNullOrWhiteSpace(sourceText))
{
try
{
var shortSummary = await summarizer.SummarizeAsync(sourceText, 80, 20);
if (!string.IsNullOrWhiteSpace(shortSummary))
{
job.ShortSummary = shortSummary;
changed++;
}
}
catch
{
// Best effort; leave for a later pass.
}
}
}
if (changed > 0)
{
await db.SaveChangesAsync(stoppingToken);
_logger.LogInformation("Backfilled tags/summaries for {Count} job fields.", changed);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Job enrichment background pass failed.");
}
await Task.Delay(TimeSpan.FromMinutes(10), stoppingToken);
}
}
}
+1
View File
@@ -13,6 +13,7 @@ namespace JobTrackerApi.Models
public string From { get; set; } = ""; // "Me" or "Company"
public string? Subject { get; set; }
public string? Channel { get; set; } // e.g. Email, Call, Note
public string? ExternalMessageId { get; set; }
public string Content { get; set; } = "";
public DateTime Date { get; set; } = DateTime.Now;
}
+14
View File
@@ -0,0 +1,14 @@
namespace JobTrackerApi.Models;
public sealed class GmailConnection
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = "";
public string GmailAddress { get; set; } = "";
public string EncryptedRefreshToken { get; set; } = "";
public string? EncryptedAccessToken { get; set; }
public DateTimeOffset? AccessTokenExpiresAt { get; set; }
public string Scope { get; set; } = "";
public DateTimeOffset ConnectedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? LastSyncedAt { get; set; }
}
+2
View File
@@ -19,6 +19,8 @@ services:
- Auth__AdminPassword=${AUTH_ADMIN_PASSWORD}
# Optional: allow Google ID-token bearer auth
- Auth__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID}
- Google__GmailClientSecret=${GOOGLE_GMAIL_CLIENT_SECRET}
- Google__GmailRedirectUri=${GOOGLE_GMAIL_REDIRECT_URI}
# Email (SMTP)
- App__PublicBaseUrl=${APP_PUBLIC_BASE_URL}
- Email__Enabled=${EMAIL_ENABLED}
@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import {
Box,
@@ -25,6 +26,8 @@ import { useToast } from "../toast";
export default function CompaniesTable() {
const { toast } = useToast();
const location = useLocation();
const navigate = useNavigate();
const [companies, setCompanies] = useState<Company[]>([]);
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<Company | null>(null);
@@ -40,6 +43,17 @@ export default function CompaniesTable() {
api.get<Company[]>("/companies").then((r) => setCompanies(r.data));
}, []);
useEffect(() => {
const params = new URLSearchParams(location.search);
const editId = Number(params.get("edit") || 0);
if (!editId || companies.length === 0) return;
const company = companies.find((c) => c.id === editId);
if (!company) return;
openEdit(company);
params.delete("edit");
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : "" }, { replace: true });
}, [companies, location.pathname, location.search, navigate]);
const openEdit = (c: Company) => {
setEditing(c);
setRecruiterName(c.recruiterName ?? "");
+249 -31
View File
@@ -3,11 +3,20 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
List,
ListItemButton,
ListItemText,
Paper,
Tab,
Tabs,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
@@ -16,7 +25,7 @@ import { alpha, useTheme } from "@mui/material/styles";
import { api } from "../api";
import { useToast } from "../toast";
import { CorrespondenceMessage } from "../types";
import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types";
function parseRawEmail(raw: string): {
subject?: string;
@@ -68,13 +77,50 @@ export default function Correspondence({ jobId }: { jobId: number }) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [importTab, setImportTab] = useState(0);
const [rawEmail, setRawEmail] = useState("");
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
const [gmailLoading, setGmailLoading] = useState(false);
const [gmailQuery, setGmailQuery] = useState("");
const [gmailMessages, setGmailMessages] = useState<GmailMessageSummary[]>([]);
const [gmailMessagesLoading, setGmailMessagesLoading] = useState(false);
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
const load = useCallback(async () => {
const res = await api.get<CorrespondenceMessage[]>(`/correspondence/${jobId}`);
setMessages(res.data);
}, [jobId]);
const loadGmailStatus = useCallback(async () => {
try {
setGmailLoading(true);
const res = await api.get<GmailStatus>("/gmail/status");
setGmailStatus(res.data);
} catch {
setGmailStatus({ connected: false });
} finally {
setGmailLoading(false);
}
}, []);
const loadGmailMessages = useCallback(async () => {
try {
setGmailMessagesLoading(true);
const res = await api.get<GmailMessageSummary[]>("/gmail/messages", {
params: {
query: gmailQuery.trim() || undefined,
maxResults: 12,
},
});
setGmailMessages(res.data);
} catch {
toast("Failed to load Gmail messages.", "error");
} finally {
setGmailMessagesLoading(false);
}
}, [gmailQuery, toast]);
useEffect(() => {
void load();
}, [load]);
@@ -85,6 +131,34 @@ export default function Correspondence({ jobId }: { jobId: number }) {
el.scrollTop = el.scrollHeight;
}, [messages.length]);
useEffect(() => {
if (!importOpen) return;
void loadGmailStatus();
}, [importOpen, loadGmailStatus]);
useEffect(() => {
if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return;
void loadGmailMessages();
}, [importOpen, importTab, gmailStatus?.connected, loadGmailMessages]);
useEffect(() => {
const onMessage = (event: MessageEvent) => {
const data = event.data as { source?: string; status?: string; message?: string };
if (data?.source !== "jobtracker-gmail-oauth") return;
if (data.status === "connected") {
toast(data.message || "Gmail connected.", "success");
void loadGmailStatus();
setImportTab(1);
void loadGmailMessages();
} else {
toast(data.message || "Gmail connection failed.", "error");
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [loadGmailMessages, loadGmailStatus, toast]);
const canSend = useMemo(() => text.trim().length > 0, [text]);
const send = async () => {
@@ -130,6 +204,45 @@ export default function Correspondence({ jobId }: { jobId: number }) {
}
};
const connectGmail = async () => {
try {
const res = await api.get<{ url: string }>("/gmail/connect-url");
const popup = window.open(res.data.url, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes");
if (!popup) {
toast("Your browser blocked the Gmail popup.", "error");
}
} catch {
toast("Failed to start Gmail connection.", "error");
}
};
const disconnectGmail = async () => {
try {
await api.delete("/gmail/connection");
setGmailStatus({ connected: false });
setGmailMessages([]);
toast("Gmail disconnected.", "success");
} catch {
toast("Failed to disconnect Gmail.", "error");
}
};
const importGmailMessage = async (messageId: string) => {
try {
setImportingMessageId(messageId);
await api.post("/gmail/import", {
jobApplicationId: jobId,
messageId,
});
await load();
toast("Email imported from Gmail.", "success");
} catch {
toast("Failed to import Gmail message.", "error");
} finally {
setImportingMessageId(null);
}
};
return (
<Box>
<Paper
@@ -190,8 +303,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
sx={{ display: "block", mt: 0.75, color: "text.secondary" }}
>
{isMe ? "Me" : "Company"}
{m.channel ? ` · ${m.channel}` : ""}
{m.date ? ` · ${new Date(m.date).toLocaleString()}` : ""}
{m.channel ? ` · ${m.channel}` : ""}
{m.date ? ` · ${new Date(m.date).toLocaleString()}` : ""}
</Typography>
</Box>
</Box>
@@ -249,38 +362,143 @@ export default function Correspondence({ jobId }: { jobId: number }) {
<Dialog open={importOpen} onClose={() => setImportOpen(false)} fullWidth maxWidth="md">
<DialogTitle>Import Email</DialogTitle>
<DialogContent>
<Typography sx={{ color: "text.secondary", mb: 1 }}>
Paste raw email text (headers optional). We parse Subject and Date when present.
</Typography>
<textarea
value={rawEmail}
onChange={(e) => setRawEmail(e.target.value)}
placeholder={"Subject: ...\nDate: ...\nFrom: ...\nTo: ...\n\nBody..."}
style={{
width: "100%",
minHeight: 220,
resize: "vertical",
padding: 12,
borderRadius: 12,
border:
theme.palette.mode === "dark"
? "1px solid rgba(148,163,184,0.25)"
: "1px solid rgba(15,23,42,0.12)",
background: theme.palette.mode === "dark" ? "rgba(2,6,23,0.25)" : "white",
color: theme.palette.text.primary,
fontFamily: "inherit",
fontSize: 14,
}}
/>
<Tabs value={importTab} onChange={(_, v) => setImportTab(v)} sx={{ mb: 2 }}>
<Tab label="Paste email" />
<Tab label="Gmail" />
</Tabs>
{importTab === 0 ? (
<>
<Typography sx={{ color: "text.secondary", mb: 1 }}>
Paste raw email text (headers optional). We parse Subject and Date when present.
</Typography>
<textarea
value={rawEmail}
onChange={(e) => setRawEmail(e.target.value)}
placeholder={"Subject: ...\nDate: ...\nFrom: ...\nTo: ...\n\nBody..."}
style={{
width: "100%",
minHeight: 220,
resize: "vertical",
padding: 12,
borderRadius: 12,
border:
theme.palette.mode === "dark"
? "1px solid rgba(148,163,184,0.25)"
: "1px solid rgba(15,23,42,0.12)",
background: theme.palette.mode === "dark" ? "rgba(2,6,23,0.25)" : "white",
color: theme.palette.text.primary,
fontFamily: "inherit",
fontSize: 14,
}}
/>
</>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Box>
<Typography sx={{ fontWeight: 800 }}>Google Gmail</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{gmailLoading ? "Checking connection..." : gmailStatus?.connected ? `Connected as ${gmailStatus.gmailAddress}` : "Connect your Gmail account to browse recent emails."}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{gmailStatus?.connected ? (
<>
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>
Refresh
</Button>
<Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>
Disconnect
</Button>
</>
) : (
<Button variant="contained" onClick={() => void connectGmail()}>
Connect Gmail
</Button>
)}
</Box>
</Box>
{gmailStatus?.connected ? (
<>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<TextField
label="Search Gmail"
value={gmailQuery}
onChange={(e) => setGmailQuery(e.target.value)}
placeholder="from:company@example.com OR interview"
size="small"
fullWidth
/>
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>
Search
</Button>
</Box>
{gmailStatus.lastSyncedAt ? (
<Chip label={`Last synced ${new Date(gmailStatus.lastSyncedAt).toLocaleString()}`} size="small" />
) : null}
<Paper variant="outlined" sx={{ maxHeight: 360, overflowY: "auto" }}>
{gmailMessagesLoading ? (
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}>
<CircularProgress size={28} />
</Box>
) : gmailMessages.length === 0 ? (
<Typography sx={{ color: "text.secondary", p: 2 }}>No Gmail messages found.</Typography>
) : (
<List disablePadding>
{gmailMessages.map((message, index) => (
<React.Fragment key={message.id}>
{index > 0 ? <Divider /> : null}
<ListItemButton sx={{ alignItems: "flex-start", py: 1.5 }}>
<ListItemText
primary={
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Typography sx={{ fontWeight: 800 }}>{message.subject || "(No subject)"}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{message.date ? new Date(message.date).toLocaleString() : ""}
</Typography>
</Box>
}
secondary={
<Box sx={{ mt: 0.5 }}>
<Typography variant="body2" sx={{ color: "text.primary" }}>
From: {message.from || "Unknown"}
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>
{message.snippet}
</Typography>
</Box>
}
/>
<Button
variant="contained"
size="small"
disabled={importingMessageId === message.id}
onClick={() => void importGmailMessage(message.id)}
>
{importingMessageId === message.id ? "Importing..." : "Import"}
</Button>
</ListItemButton>
</React.Fragment>
))}
</List>
)}
</Paper>
</>
) : null}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setImportOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={importEmail}>
Log Email
</Button>
<Button onClick={() => setImportOpen(false)}>Close</Button>
{importTab === 0 ? (
<Button variant="contained" onClick={importEmail}>
Log Email
</Button>
) : null}
</DialogActions>
</Dialog>
</Box>
);
}
@@ -10,6 +10,7 @@ import {
DialogTitle,
Tab,
Tabs,
TextField,
Typography,
} from "@mui/material";
@@ -55,12 +56,16 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
const [isAdmin, setIsAdmin] = useState(false);
const [followUpDraft, setFollowUpDraft] = useState<FollowUpDraft | null>(null);
const [loadingDraft, setLoadingDraft] = useState(false);
const [sendingDraft, setSendingDraft] = useState(false);
const [draftRecipient, setDraftRecipient] = useState("");
const [draftSubject, setDraftSubject] = useState("");
const [draftBody, setDraftBody] = useState("");
useEffect(() => {
if (!open || !jobId) return;
setTab(0);
setFollowUpDraft(null);
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => setJob(r.data));
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => { setJob(r.data); setDraftRecipient(r.data.company?.recruiterEmail ?? ""); });
api
.get(`/auth/me`)
.then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin"))))
@@ -76,7 +81,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
setLoadingDraft(true);
api
.get<FollowUpDraft>(`/jobapplications/${jobId}/followup-draft`)
.then((r) => setFollowUpDraft(r.data))
.then((r) => { setFollowUpDraft(r.data); setDraftSubject(r.data.subject); setDraftBody(r.data.body); })
.catch(() => setFollowUpDraft(null))
.finally(() => setLoadingDraft(false));
}, [open, jobId, tab, followUpDraft]);
@@ -216,17 +221,31 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
<Typography variant="overline">Suggested send date</Typography>
<Typography>{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()}</Typography>
</Box>
<Box>
<Typography variant="overline">Subject</Typography>
<Typography sx={{ fontWeight: 700 }}>{followUpDraft.subject}</Typography>
</Box>
<Box>
<Typography variant="overline">Draft</Typography>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{followUpDraft.body}</Typography>
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button variant="contained" onClick={() => navigator.clipboard.writeText(`${followUpDraft.subject}\n\n${followUpDraft.body}`)}>
Copy draft
<TextField label="Recipient" value={draftRecipient} onChange={(e) => setDraftRecipient(e.target.value)} helperText="Defaults to the company recruiter email when available." />
<TextField label="Subject" value={draftSubject} onChange={(e) => setDraftSubject(e.target.value)} />
<TextField label="Draft" multiline minRows={8} value={draftBody} onChange={(e) => setDraftBody(e.target.value)} />
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Button variant="outlined" onClick={() => navigator.clipboard.writeText(`${draftSubject}\n\n${draftBody}`)}>Copy draft</Button>
<Button
variant="contained"
disabled={sendingDraft || !draftSubject.trim() || !draftBody.trim()}
onClick={async () => {
if (!jobId) return;
setSendingDraft(true);
try {
await api.post(`/jobapplications/${jobId}/send-followup`, {
toEmail: draftRecipient || null,
subject: draftSubject,
body: draftBody,
nextFollowUpAt: followUpDraft.suggestedSendOn || null,
});
setJob((prev) => prev ? { ...prev, followUpAt: followUpDraft.suggestedSendOn } : prev);
} finally {
setSendingDraft(false);
}
}}
>
{sendingDraft ? "Sending..." : "Send and log email"}
</Button>
</Box>
</Box>
@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import {
Box,
@@ -122,6 +123,8 @@ function statusTone(status: string): string {
export default function JobTable({ refreshToken, pageSize, onPageSizeChange, columns, onColumnsChange, mode = "jobs" }: Props) {
const theme = useTheme();
const { toast } = useToast();
const location = useLocation();
const navigate = useNavigate();
const [jobs, setJobs] = useState<JobApplication[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
@@ -167,6 +170,17 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
});
}, [params, refreshToken, reloadToken]);
useEffect(() => {
const paramsSearch = new URLSearchParams(location.search);
const openId = Number(paramsSearch.get("open") || 0);
if (!openId || jobs.length === 0) return;
const job = jobs.find((j) => j.id === openId);
if (!job) return;
setDetailsJobId(openId);
paramsSearch.delete("open");
navigate({ pathname: location.pathname, search: paramsSearch.toString() ? `?${paramsSearch.toString()}` : "" }, { replace: true });
}, [jobs, location.pathname, location.search, navigate]);
const requestSort = (key: typeof sortBy) => {
if (sortBy === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else {
@@ -101,13 +101,13 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
id: `job-${job.id}`,
label: `${job.company?.name ?? "Company"} - ${job.jobTitle}`,
hint: "Open job list and search result",
action: () => onNavigate(`/jobs`),
action: () => onNavigate(`/jobs?open=${job.id}`),
})),
...companies.slice(0, 6).map((company) => ({
id: `company-${company.id}`,
label: company.name,
hint: "Open companies",
action: () => onNavigate(`/companies`),
action: () => onNavigate(`/companies?edit=${company.id}`),
})),
];
+18
View File
@@ -58,6 +58,24 @@ export interface CorrespondenceMessage {
date: string;
}
export interface GmailStatus {
connected: boolean;
gmailAddress?: string;
connectedAt?: string;
lastSyncedAt?: string;
}
export interface GmailMessageSummary {
id: string;
threadId: string;
subject: string;
from: string;
to: string;
date?: string;
snippet: string;
}
export interface JobImportResult {
title?: string;
company?: string;