From 57d93cf234a1a0708bf1307635a4f25c4d95d9a8 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 22 Mar 2026 14:43:30 +0100 Subject: [PATCH] feat: add smart gmail thread import and search suggestions --- JobTrackerApi/Controllers/GmailController.cs | 122 ++++--- .../src/components/Correspondence.tsx | 329 +++++++----------- 2 files changed, 206 insertions(+), 245 deletions(-) diff --git a/JobTrackerApi/Controllers/GmailController.cs b/JobTrackerApi/Controllers/GmailController.cs index 166a98b..c4d5ebe 100644 --- a/JobTrackerApi/Controllers/GmailController.cs +++ b/JobTrackerApi/Controllers/GmailController.cs @@ -24,6 +24,10 @@ public sealed class GmailController : ControllerBase _cfg = cfg; } + public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId); + public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId); + public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds); + [HttpGet("status")] public async Task Status(CancellationToken cancellationToken) { @@ -93,8 +97,6 @@ public sealed class GmailController : ControllerBase return Ok(items); } - public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId); - [HttpPost("import")] public async Task Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken) { @@ -115,47 +117,9 @@ public sealed class GmailController : ControllerBase 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 messageDate = detail.Date?.LocalDateTime ?? DateTime.Now; - - 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 = messageDate, - }; - - _db.Correspondences.Add(message); - if (job.Company is not null) - { - job.Company.LastContactedAt = messageDate; - } - - if (!isMe && (!job.ResponseReceived || job.ResponseDate is null || messageDate < job.ResponseDate.Value)) - { - var oldResponse = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}"; - job.ResponseReceived = true; - job.ResponseDate = messageDate; - _db.JobEvents.Add(new JobEvent - { - JobApplicationId = job.Id, - Type = "ReplyReceived", - OldValue = oldResponse, - NewValue = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}", - Note = detail.Subject, - At = messageDate - }); - } - + var created = await ImportSingleMessageAsync(ownerUserId, job, request.MessageId, cancellationToken); await _db.SaveChangesAsync(cancellationToken); - return Ok(message); + return Ok(created); } catch (Exception ex) { @@ -163,6 +127,80 @@ public sealed class GmailController : ControllerBase } } + [HttpPost("import-thread")] + public async Task> ImportThread([FromBody] ImportGmailThreadRequest request, CancellationToken cancellationToken) + { + if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required."); + if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required."); + if (request.MessageIds is null || request.MessageIds.Length == 0) return BadRequest("At least one 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 distinctIds = request.MessageIds.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct(StringComparer.Ordinal).ToList(); + var existingIds = await _db.Correspondences + .Where(x => x.JobApplicationId == request.JobApplicationId && x.ExternalMessageId != null && distinctIds.Contains(x.ExternalMessageId)) + .Select(x => x.ExternalMessageId!) + .ToListAsync(cancellationToken); + + var skipped = existingIds.Count; + var imported = 0; + foreach (var messageId in distinctIds) + { + if (existingIds.Contains(messageId, StringComparer.Ordinal)) continue; + await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken); + imported++; + } + + await _db.SaveChangesAsync(cancellationToken); + return Ok(new GmailImportResultDto(imported, skipped, request.ThreadId)); + } + + private async Task ImportSingleMessageAsync(string ownerUserId, JobApplication job, string messageId, CancellationToken cancellationToken) + { + var detail = await _gmail.GetMessageAsync(ownerUserId, messageId, cancellationToken); + var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken); + var gmailAddress = me?.GmailAddress ?? string.Empty; + var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase); + var messageDate = detail.Date?.LocalDateTime ?? DateTime.Now; + + var message = new Correspondence + { + JobApplicationId = job.Id, + 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 = messageDate, + }; + + _db.Correspondences.Add(message); + if (job.Company is not null) + { + job.Company.LastContactedAt = messageDate; + } + + if (!isMe && (!job.ResponseReceived || job.ResponseDate is null || messageDate < job.ResponseDate.Value)) + { + var oldResponse = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}"; + job.ResponseReceived = true; + job.ResponseDate = messageDate; + _db.JobEvents.Add(new JobEvent + { + JobApplicationId = job.Id, + Type = "ReplyReceived", + OldValue = oldResponse, + NewValue = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}", + Note = detail.Subject, + At = messageDate + }); + } + + return message; + } + private string GetRequiredOwnerUserId() { return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") diff --git a/job-tracker-ui/src/components/Correspondence.tsx b/job-tracker-ui/src/components/Correspondence.tsx index 0b23f1c..6d691fb 100644 --- a/job-tracker-ui/src/components/Correspondence.tsx +++ b/job-tracker-ui/src/components/Correspondence.tsx @@ -4,7 +4,6 @@ import { Box, Button, Chip, - IconButton, CircularProgress, Dialog, DialogActions, @@ -24,19 +23,16 @@ import { } from "@mui/material"; import { alpha, useTheme } from "@mui/material/styles"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome"; +import MailOutlineIcon from "@mui/icons-material/MailOutline"; +import { IconButton } from "@mui/material"; import { api } from "../api"; import { useToast } from "../toast"; import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types"; import { useDialogActions } from "../dialogs"; -function parseRawEmail(raw: string): { - subject?: string; - date?: string; - from?: string; - to?: string; - body: string; -} { +function parseRawEmail(raw: string): { subject?: string; date?: string; from?: string; to?: string; body: string } { const lines = raw.replace(/\r\n/g, "\n").split("\n"); const headers: Record = {}; let i = 0; @@ -50,25 +46,35 @@ function parseRawEmail(raw: string): { const idx = line.indexOf(":"); if (idx <= 0) continue; - const k = line.slice(0, idx).trim().toLowerCase(); const v = line.slice(idx + 1).trim(); headers[k] = headers[k] ? `${headers[k]} ${v}` : v; } const body = lines.slice(i).join("\n").trim(); - const subject = headers["subject"]; - const from = headers["from"]; - const to = headers["to"]; const dateRaw = headers["date"]; - let iso: string | undefined; if (dateRaw) { const d = new Date(dateRaw); if (!Number.isNaN(+d)) iso = d.toISOString(); } - return { subject, from, to, date: iso, body }; + return { subject: headers["subject"], from: headers["from"], to: headers["to"], date: iso, body }; +} + +function scoreMessage(message: GmailMessageSummary, query: string, messages: CorrespondenceMessage[]) { + const hay = `${message.subject} ${message.from} ${message.to} ${message.snippet}`.toLowerCase(); + const q = query.toLowerCase(); + let score = 0; + if (q && hay.includes(q)) score += 8; + if (/interview|application|recruit|follow up|follow-up|position|role/.test(hay)) score += 2; + if (messages.some((m) => m.subject && message.subject && m.subject.toLowerCase() === message.subject.toLowerCase())) score -= 4; + if (message.date) { + const ageDays = Math.abs((Date.now() - new Date(message.date).getTime()) / 86400000); + if (ageDays <= 30) score += 3; + else if (ageDays <= 120) score += 1; + } + return score; } export default function Correspondence({ jobId }: { jobId: number }) { @@ -90,6 +96,7 @@ export default function Correspondence({ jobId }: { jobId: number }) { const [gmailMessages, setGmailMessages] = useState([]); const [gmailMessagesLoading, setGmailMessagesLoading] = useState(false); const [importingMessageId, setImportingMessageId] = useState(null); + const [importingThreadId, setImportingThreadId] = useState(null); const load = useCallback(async () => { const res = await api.get(`/correspondence/${jobId}`); @@ -119,10 +126,7 @@ export default function Correspondence({ jobId }: { jobId: number }) { }); setGmailMessages(res.data); } catch (error: any) { - const message = - error?.response?.data && typeof error.response.data === "string" - ? error.response.data - : "Failed to load Gmail messages."; + const message = error?.response?.data && typeof error.response.data === "string" ? error.response.data : "Failed to load Gmail messages."; toast(message, "error"); } finally { setGmailMessagesLoading(false); @@ -169,16 +173,37 @@ export default function Correspondence({ jobId }: { jobId: number }) { const canSend = useMemo(() => text.trim().length > 0, [text]); + const suggestedQueries = useMemo(() => { + const subjectTerms = messages.map((m) => m.subject).filter(Boolean) as string[]; + const uniqueSubjects = Array.from(new Set(subjectTerms)).slice(0, 2); + const companyTerms = Array.from(new Set(messages.filter((m) => m.from === "Company").map((m) => m.subject).filter(Boolean) as string[])).slice(0, 2); + + return [ + { label: "Recent recruiting mail", value: "newer_than:180d (interview OR recruiter OR application OR position)" }, + { label: "Inbox only", value: "label:inbox newer_than:120d" }, + { label: "Sent follow-ups", value: "in:sent newer_than:180d follow up" }, + ...uniqueSubjects.map((s) => ({ label: `Subject: ${s}`, value: `subject:"${s}"` })), + ...companyTerms.map((s) => ({ label: `Related topic: ${s}`, value: `"${s}" newer_than:180d` })), + ].slice(0, 6); + }, [messages]); + + const rankedMessages = useMemo(() => { + return [...gmailMessages].sort((a, b) => scoreMessage(b, gmailQuery, messages) - scoreMessage(a, gmailQuery, messages)); + }, [gmailMessages, gmailQuery, messages]); + + const groupedByThread = useMemo(() => { + const map = new Map(); + for (const message of rankedMessages) { + const key = message.threadId || message.id; + map.set(key, [...(map.get(key) ?? []), message]); + } + return Array.from(map.entries()).map(([threadId, items]) => ({ threadId, items })); + }, [rankedMessages]); + const send = async () => { if (!canSend) return; - try { - await api.post("/correspondence", { - jobApplicationId: jobId, - from, - content: text, - }); - + await api.post("/correspondence", { jobApplicationId: jobId, from, content: text }); setText(""); await load(); } catch { @@ -202,7 +227,6 @@ export default function Correspondence({ jobId }: { jobId: number }) { content: parsed.body || rawEmail, date: parsed.date ?? null, }); - setImportOpen(false); setRawEmail(""); await load(); @@ -216,9 +240,7 @@ export default function Correspondence({ jobId }: { jobId: number }) { 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"); - } + if (!popup) toast("Your browser blocked the Gmail popup.", "error"); } catch { toast("Failed to start Gmail connection.", "error"); } @@ -249,80 +271,49 @@ export default function Correspondence({ jobId }: { jobId: number }) { const importGmailMessage = async (messageId: string) => { try { setImportingMessageId(messageId); - await api.post("/gmail/import", { - jobApplicationId: jobId, - messageId, - }); + await api.post("/gmail/import", { jobApplicationId: jobId, messageId }); await load(); toast("Email imported from Gmail.", "success"); } catch (error: any) { - const message = - error?.response?.data && typeof error.response.data === "string" - ? error.response.data - : "Failed to import Gmail message."; + const message = error?.response?.data && typeof error.response.data === "string" ? error.response.data : "Failed to import Gmail message."; toast(message, "error"); } finally { setImportingMessageId(null); } }; + const importGmailThread = async (threadId: string, messageIds: string[]) => { + try { + setImportingThreadId(threadId); + const res = await api.post<{ imported: number; skipped: number; threadId?: string }>("/gmail/import-thread", { jobApplicationId: jobId, threadId, messageIds }); + await load(); + toast(`Imported ${res.data.imported} messages${res.data.skipped ? `, skipped ${res.data.skipped} duplicates` : ""}.`, "success"); + } catch (error: any) { + const message = error?.response?.data && typeof error.response.data === "string" ? error.response.data : "Failed to import Gmail thread."; + toast(message, "error"); + } finally { + setImportingThreadId(null); + } + }; + return ( - + {messages.length === 0 ? ( - - No messages yet. - + No messages yet. ) : ( {messages.map((m) => { const isMe = (m.from || "").toLowerCase() === "me"; const accent = isMe ? theme.palette.primary.main : theme.palette.warning.main; return ( - - - {m.subject ? ( - - {m.subject} - - ) : null} - - - {m.content} - - + + + {m.subject ? {m.subject} : null} + {m.content} - {isMe ? "Me" : "Company"} - {m.channel ? ` - ${m.channel}` : ""} - {m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""} + {isMe ? "Me" : "Company"}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""} void deleteMessage(m.id)} sx={{ color: "text.secondary" }}> @@ -336,49 +327,17 @@ export default function Correspondence({ jobId }: { jobId: number }) { )} - - { - if (v) setFrom(v); - }} - size="small" - > + + v && setFrom(v)} size="small"> Me Company - + - -