feat: add smart gmail thread import and search suggestions
This commit is contained in:
@@ -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<IActionResult> 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<IActionResult> 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<ActionResult<GmailImportResultDto>> 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<Correspondence> 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")
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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<GmailMessageSummary[]>([]);
|
||||
const [gmailMessagesLoading, setGmailMessagesLoading] = useState(false);
|
||||
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
|
||||
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const res = await api.get<CorrespondenceMessage[]>(`/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<string, GmailMessageSummary[]>();
|
||||
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 (
|
||||
<Box>
|
||||
<Paper
|
||||
ref={scrollRef}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
maxHeight: 360,
|
||||
overflowY: "auto",
|
||||
background:
|
||||
theme.palette.mode === "dark"
|
||||
? "rgba(15,23,42,0.45)"
|
||||
: "rgba(255,255,255,0.75)",
|
||||
backdropFilter: "blur(8px)",
|
||||
}}
|
||||
>
|
||||
<Paper ref={scrollRef} sx={{ p: 1.5, maxHeight: 360, overflowY: "auto", background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.45)" : "rgba(255,255,255,0.75)", backdropFilter: "blur(8px)" }}>
|
||||
{messages.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>
|
||||
No messages yet.
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>No messages yet.</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{messages.map((m) => {
|
||||
const isMe = (m.from || "").toLowerCase() === "me";
|
||||
const accent = isMe ? theme.palette.primary.main : theme.palette.warning.main;
|
||||
return (
|
||||
<Box
|
||||
key={m.id}
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: isMe ? "flex-end" : "flex-start",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: "80%",
|
||||
borderRadius: 3,
|
||||
p: 1.25,
|
||||
border: `1px solid ${alpha(accent, theme.palette.mode === "dark" ? 0.32 : 0.22)}`,
|
||||
background: alpha(accent, theme.palette.mode === "dark" ? 0.14 : 0.1),
|
||||
color: "text.primary",
|
||||
}}
|
||||
>
|
||||
{m.subject ? (
|
||||
<Typography sx={{ fontWeight: 800, mb: 0.5 }}>
|
||||
{m.subject}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>
|
||||
{m.content}
|
||||
</Typography>
|
||||
|
||||
<Box key={m.id} sx={{ display: "flex", justifyContent: isMe ? "flex-end" : "flex-start" }}>
|
||||
<Box sx={{ maxWidth: "80%", borderRadius: 3, p: 1.25, border: `1px solid ${alpha(accent, theme.palette.mode === "dark" ? 0.32 : 0.22)}`, background: alpha(accent, theme.palette.mode === "dark" ? 0.14 : 0.1), color: "text.primary" }}>
|
||||
{m.subject ? <Typography sx={{ fontWeight: 800, mb: 0.5 }}>{m.subject}</Typography> : null}
|
||||
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{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()}` : ""}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => void deleteMessage(m.id)} sx={{ color: "text.secondary" }}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
@@ -336,49 +327,17 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center", mt: 1.5, flexWrap: "wrap" }}>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
value={from}
|
||||
onChange={(_, v) => {
|
||||
if (v) setFrom(v);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start", mt: 1.5, flexWrap: "wrap" }}>
|
||||
<ToggleButtonGroup exclusive value={from} onChange={(_, v) => v && setFrom(v)} size="small">
|
||||
<ToggleButton value="Me">Me</ToggleButton>
|
||||
<ToggleButton value="Company">Company</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<Button variant="outlined" size="small" onClick={() => setImportOpen(true)}>
|
||||
Import Email
|
||||
</Button>
|
||||
<Button variant="outlined" size="small" onClick={() => setImportOpen(true)}>Import Email</Button>
|
||||
|
||||
<Box sx={{ flex: "1 1 280px" }}>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Log an email/call note..."
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: 70,
|
||||
resize: "vertical",
|
||||
padding: 10,
|
||||
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>
|
||||
<TextField label="Log note or message" value={text} onChange={(e) => setText(e.target.value)} multiline minRows={3} sx={{ flex: "1 1 320px" }} helperText={`${text.length} characters`} />
|
||||
|
||||
<Button variant="contained" onClick={send} disabled={!canSend}>
|
||||
Add
|
||||
</Button>
|
||||
<Button variant="contained" onClick={send} disabled={!canSend}>Add</Button>
|
||||
</Box>
|
||||
|
||||
<Dialog open={importOpen} onClose={() => setImportOpen(false)} fullWidth maxWidth="md">
|
||||
@@ -391,17 +350,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
|
||||
{importTab === 0 ? (
|
||||
<>
|
||||
<Typography sx={{ color: "text.secondary", mb: 1 }}>
|
||||
Paste raw email text (headers optional). We parse Subject and Date when present.
|
||||
</Typography>
|
||||
<TextField
|
||||
multiline
|
||||
minRows={10}
|
||||
fullWidth
|
||||
value={rawEmail}
|
||||
onChange={(e) => setRawEmail(e.target.value)}
|
||||
placeholder={"Subject: ...\nDate: ...\nFrom: ...\nTo: ...\n\nBody..."}
|
||||
/>
|
||||
<Typography sx={{ color: "text.secondary", mb: 1 }}>Paste raw email text (headers optional). We parse Subject and Date when present.</Typography>
|
||||
<TextField multiline minRows={10} fullWidth value={rawEmail} onChange={(e) => setRawEmail(e.target.value)} placeholder={"Subject: ...\nDate: ...\nFrom: ...\nTo: ...\n\nBody..."} />
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
@@ -409,91 +359,68 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
<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."}
|
||||
{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="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>
|
||||
<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 sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{suggestedQueries.map((item) => (
|
||||
<Chip key={item.label} icon={<AutoAwesomeIcon />} label={item.label} clickable variant="outlined" onClick={() => setGmailQuery(item.value)} />
|
||||
))}
|
||||
</Box>
|
||||
{gmailStatus.lastSyncedAt ? (
|
||||
<Chip label={`Last synced ${new Date(gmailStatus.lastSyncedAt).toLocaleString()}`} size="small" />
|
||||
) : null}
|
||||
<Paper variant="outlined" sx={{ maxHeight: 360, overflowY: "auto" }}>
|
||||
<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: 420, overflowY: "auto" }}>
|
||||
{gmailMessagesLoading ? (
|
||||
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : gmailMessages.length === 0 ? (
|
||||
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
|
||||
) : groupedByThread.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>
|
||||
{groupedByThread.map(({ threadId, items }, threadIndex) => (
|
||||
<React.Fragment key={threadId}>
|
||||
{threadIndex > 0 ? <Divider /> : null}
|
||||
<Box sx={{ p: 1.5, backgroundColor: alpha(theme.palette.primary.main, 0.04) }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 800 }}>{items[0]?.subject || "(No subject)"}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{items.length} message{items.length === 1 ? "" : "s"} in thread</Typography>
|
||||
</Box>
|
||||
<Button startIcon={<MailOutlineIcon />} variant="outlined" size="small" disabled={importingThreadId === threadId} onClick={() => void importGmailThread(threadId, items.map((x) => x.id))}>
|
||||
{importingThreadId === threadId ? "Importing..." : "Import thread"}
|
||||
</Button>
|
||||
</Box>
|
||||
{items.map((message, index) => (
|
||||
<React.Fragment key={message.id}>
|
||||
{index > 0 ? <Divider sx={{ my: 1 }} /> : null}
|
||||
<ListItemButton sx={{ alignItems: "flex-start", px: 0, py: 1 }}>
|
||||
<ListItemText
|
||||
primary={<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}><Typography sx={{ fontWeight: 700 }}>{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>
|
||||
))}
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
@@ -506,11 +433,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setImportOpen(false)}>Close</Button>
|
||||
{importTab === 0 ? (
|
||||
<Button variant="contained" onClick={importEmail}>
|
||||
Log Email
|
||||
</Button>
|
||||
) : null}
|
||||
{importTab === 0 ? <Button variant="contained" onClick={importEmail}>Log Email</Button> : null}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user