Add OAth flow for Gmail and update tables and UI
This commit is contained in:
@@ -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 ?? "");
|
||||
|
||||
@@ -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}`),
|
||||
})),
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user