feat(email): add new-message drafting
This commit is contained in:
@@ -321,6 +321,56 @@ describe('CorrespondenceInboxPage', () => {
|
|||||||
expect(screen.getByRole('button', { name: /review and send/i })).toBeEnabled();
|
expect(screen.getByRole('button', { name: /review and send/i })).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('starts a new-message draft only from an owned job and send-capable provider choice', async () => {
|
||||||
|
const original = mockedApi.get.getMockImplementation();
|
||||||
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||||
|
if (url === '/email/providers') return Promise.resolve({ data: [
|
||||||
|
{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true },
|
||||||
|
{ provider: 'microsoft', displayName: 'Outlook', connected: true, address: 'owner@outlook.test', canRead: true, canSend: false },
|
||||||
|
] } as any);
|
||||||
|
if (url === '/jobapplications') return Promise.resolve({ data: { items: [
|
||||||
|
{ id: 42, jobTitle: 'Backend Engineer', company: { name: 'Acme Systems' } },
|
||||||
|
] } } as any);
|
||||||
|
return original!(url, config);
|
||||||
|
});
|
||||||
|
mockedApi.post.mockImplementation((url: string) => {
|
||||||
|
if (url === '/email/drafts') return Promise.resolve({ data: {
|
||||||
|
id: 'draft-new', jobApplicationId: 42, provider: 'gmail', to: 'recruiter@acme.test', subject: 'Application question', bodyText: 'Hello from a synthetic draft.',
|
||||||
|
threadId: null, clientRequestId: 'server-request-id', revision: 1, createdAtUtc: new Date().toISOString(), updatedAtUtc: new Date().toISOString(),
|
||||||
|
} } as any);
|
||||||
|
return Promise.reject(new Error(`Unexpected POST ${url}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
const compose = await screen.findByRole('button', { name: /compose new email/i });
|
||||||
|
await waitFor(() => expect(compose).toBeEnabled());
|
||||||
|
fireEvent.click(compose);
|
||||||
|
expect(await screen.findByRole('heading', { name: /compose new email/i })).toBeInTheDocument();
|
||||||
|
const providerSelect = screen.getByRole('combobox', { name: /sending provider/i });
|
||||||
|
expect(providerSelect).toHaveTextContent(/Gmail/i);
|
||||||
|
fireEvent.mouseDown(providerSelect);
|
||||||
|
const gmailOption = await screen.findByRole('option', { name: /Gmail/i });
|
||||||
|
expect(screen.queryByRole('option', { name: /Outlook/i })).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(gmailOption);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /start draft/i }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('textbox', { name: /^provider$/i })).toHaveValue('Gmail · owner@gmail.test');
|
||||||
|
expect(screen.getByLabelText(/thread/i)).toHaveValue('New message');
|
||||||
|
fireEvent.change(screen.getByLabelText(/recipient/i), { target: { value: 'recruiter@acme.test' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Application question' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Hello from a synthetic draft.' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^save draft$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts', {
|
||||||
|
jobApplicationId: 42,
|
||||||
|
provider: 'gmail',
|
||||||
|
to: 'recruiter@acme.test',
|
||||||
|
subject: 'Application question',
|
||||||
|
bodyText: 'Hello from a synthetic draft.',
|
||||||
|
threadId: null,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
test('does not offer retry when delivery is uncertain', async () => {
|
test('does not offer retry when delivery is uncertain', async () => {
|
||||||
const original = mockedApi.get.getMockImplementation();
|
const original = mockedApi.get.getMockImplementation();
|
||||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ type EmailProviderStatus = {
|
|||||||
canSend: boolean;
|
canSend: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type JobChoice = {
|
||||||
|
id: number;
|
||||||
|
jobTitle: string;
|
||||||
|
company?: { name?: string | null } | null;
|
||||||
|
};
|
||||||
|
|
||||||
export function emailProviderStatusLabel(provider: EmailProviderStatus) {
|
export function emailProviderStatusLabel(provider: EmailProviderStatus) {
|
||||||
if (!provider.connected) return `${provider.displayName}: Not connected`;
|
if (!provider.connected) return `${provider.displayName}: Not connected`;
|
||||||
const identity = provider.address || "Connected account";
|
const identity = provider.address || "Connected account";
|
||||||
@@ -110,6 +116,12 @@ export default function CorrespondenceInboxPage() {
|
|||||||
const { confirm } = useConfirm();
|
const { confirm } = useConfirm();
|
||||||
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
|
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
|
||||||
const [providers, setProviders] = useState<EmailProviderStatus[]>([]);
|
const [providers, setProviders] = useState<EmailProviderStatus[]>([]);
|
||||||
|
const [providerStatusLoaded, setProviderStatusLoaded] = useState(false);
|
||||||
|
const [jobs, setJobs] = useState<JobChoice[]>([]);
|
||||||
|
const [jobsLoaded, setJobsLoaded] = useState(false);
|
||||||
|
const [composeSetupOpen, setComposeSetupOpen] = useState(false);
|
||||||
|
const [composeJobId, setComposeJobId] = useState<number | "">("");
|
||||||
|
const [composeProvider, setComposeProvider] = useState("");
|
||||||
const [providerStatusError, setProviderStatusError] = useState(false);
|
const [providerStatusError, setProviderStatusError] = useState(false);
|
||||||
const [selectedMessageId, setSelectedMessageId] = useState<number | null>(null);
|
const [selectedMessageId, setSelectedMessageId] = useState<number | null>(null);
|
||||||
const [messageDetail, setMessageDetail] = useState<EmailMessageDetail | null>(null);
|
const [messageDetail, setMessageDetail] = useState<EmailMessageDetail | null>(null);
|
||||||
@@ -161,7 +173,15 @@ export default function CorrespondenceInboxPage() {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
setProviders([]);
|
setProviders([]);
|
||||||
setProviderStatusError(true);
|
setProviderStatusError(true);
|
||||||
});
|
})
|
||||||
|
.finally(() => setProviderStatusLoaded(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<{ items?: JobChoice[] }>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } })
|
||||||
|
.then((response) => setJobs(Array.isArray(response.data?.items) ? response.data.items : []))
|
||||||
|
.catch(() => setJobs([]))
|
||||||
|
.finally(() => setJobsLoaded(true));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadStoredDrafts = useCallback(async () => {
|
const loadStoredDrafts = useCallback(async () => {
|
||||||
@@ -182,6 +202,7 @@ export default function CorrespondenceInboxPage() {
|
|||||||
const inbound = items.filter((item) => item.direction === "inbound").length;
|
const inbound = items.filter((item) => item.direction === "inbound").length;
|
||||||
return { linked, inbound };
|
return { linked, inbound };
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
const sendCapableProviders = useMemo(() => providers.filter((provider) => provider.connected && provider.canSend), [providers]);
|
||||||
|
|
||||||
const showMessage = async (item: CorrespondenceInboxItem) => {
|
const showMessage = async (item: CorrespondenceInboxItem) => {
|
||||||
if (selectedMessageId === item.id) {
|
if (selectedMessageId === item.id) {
|
||||||
@@ -273,6 +294,41 @@ export default function CorrespondenceInboxPage() {
|
|||||||
setDraftError(null);
|
setDraftError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openComposeSetup = () => {
|
||||||
|
setComposeJobId((current) => current || jobs[0]?.id || "");
|
||||||
|
setComposeProvider((current) => current || sendCapableProviders[0]?.provider || "");
|
||||||
|
setComposeSetupOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startNewMessage = async () => {
|
||||||
|
const job = jobs.find((candidate) => candidate.id === composeJobId);
|
||||||
|
const provider = sendCapableProviders.find((candidate) => candidate.provider === composeProvider);
|
||||||
|
if (!job || !provider) return;
|
||||||
|
if (draft?.bodyText.trim() && !(await confirm({
|
||||||
|
title: "Replace email draft?",
|
||||||
|
message: draft.id ? "Your current draft is already saved and can be resumed later." : "Your current unsaved draft text will be lost.",
|
||||||
|
confirmLabel: "Replace draft",
|
||||||
|
destructive: !draft.id,
|
||||||
|
}))) return;
|
||||||
|
setDraft({
|
||||||
|
jobApplicationId: job.id,
|
||||||
|
companyName: job.company?.name || "Unknown company",
|
||||||
|
jobTitle: job.jobTitle || "Unknown role",
|
||||||
|
provider: provider.provider,
|
||||||
|
providerName: provider.displayName,
|
||||||
|
fromAddress: provider.address || "Connected account",
|
||||||
|
to: "",
|
||||||
|
subject: "",
|
||||||
|
bodyText: "",
|
||||||
|
threadId: "",
|
||||||
|
clientRequestId: globalThis.crypto.randomUUID(),
|
||||||
|
});
|
||||||
|
setComposeSetupOpen(false);
|
||||||
|
setSendResult(null);
|
||||||
|
setSendError(null);
|
||||||
|
setDraftError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const updateDraft = (changes: Partial<Pick<EmailDraft, "to" | "subject" | "bodyText">>) => {
|
const updateDraft = (changes: Partial<Pick<EmailDraft, "to" | "subject" | "bodyText">>) => {
|
||||||
setDraft((current) => current ? {
|
setDraft((current) => current ? {
|
||||||
...current,
|
...current,
|
||||||
@@ -463,6 +519,7 @@ export default function CorrespondenceInboxPage() {
|
|||||||
{view === "inbox" ? <Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" /> : null}
|
{view === "inbox" ? <Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" /> : null}
|
||||||
<Button variant={view === "inbox" ? "contained" : "text"} size="small" onClick={() => setSearchParams({})}>Linked messages</Button>
|
<Button variant={view === "inbox" ? "contained" : "text"} size="small" onClick={() => setSearchParams({})}>Linked messages</Button>
|
||||||
<Button variant={view === "review" ? "contained" : "text"} size="small" onClick={() => setSearchParams({ view: "review" })}>Review suggestions</Button>
|
<Button variant={view === "review" ? "contained" : "text"} size="small" onClick={() => setSearchParams({ view: "review" })}>Review suggestions</Button>
|
||||||
|
<Button variant="outlined" size="small" onClick={openComposeSetup} disabled={jobs.length === 0 || sendCapableProviders.length === 0}>Compose new email</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -480,6 +537,8 @@ export default function CorrespondenceInboxPage() {
|
|||||||
{providerStatusError ? (
|
{providerStatusError ? (
|
||||||
<Alert severity="warning" sx={{ mb: 2 }}>Email provider status is temporarily unavailable. Saved JobTracker correspondence remains available.</Alert>
|
<Alert severity="warning" sx={{ mb: 2 }}>Email provider status is temporarily unavailable. Saved JobTracker correspondence remains available.</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
{jobsLoaded && jobs.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>Add a job before composing a new email.</Alert> : null}
|
||||||
|
{providerStatusLoaded && !providerStatusError && sendCapableProviders.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>Reconnect Gmail or Outlook with send access before composing a new email.</Alert> : null}
|
||||||
{draftError && !draft ? <Alert severity="warning" sx={{ mb: 2 }}>{draftError}</Alert> : null}
|
{draftError && !draft ? <Alert severity="warning" sx={{ mb: 2 }}>{draftError}</Alert> : null}
|
||||||
|
|
||||||
{storedDrafts.length > 0 ? (
|
{storedDrafts.length > 0 ? (
|
||||||
@@ -495,6 +554,30 @@ export default function CorrespondenceInboxPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{composeSetupOpen ? (
|
||||||
|
<Paper component="section" aria-labelledby="compose-new-title" variant="outlined" sx={{ p: 2, borderRadius: 3, mb: 2 }}>
|
||||||
|
<Typography id="compose-new-title" component="h2" variant="h6" sx={{ fontWeight: 900, mb: 1.5 }}>Compose new email</Typography>
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr auto" }, gap: 1.25, alignItems: "center" }}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel id="compose-job-label">Job</InputLabel>
|
||||||
|
<Select labelId="compose-job-label" value={composeJobId} label="Job" onChange={(event) => setComposeJobId(Number(event.target.value))}>
|
||||||
|
{jobs.map((job) => <MenuItem key={job.id} value={job.id}>{job.company?.name || "Unknown company"} · {job.jobTitle}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel id="compose-provider-label">Sending provider</InputLabel>
|
||||||
|
<Select labelId="compose-provider-label" value={composeProvider} label="Sending provider" onChange={(event) => setComposeProvider(String(event.target.value))}>
|
||||||
|
{sendCapableProviders.map((provider) => <MenuItem key={provider.provider} value={provider.provider}>{provider.displayName} · {provider.address || "Connected account"}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
|
<Button onClick={() => setComposeSetupOpen(false)}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={() => void startNewMessage()} disabled={!composeJobId || !composeProvider}>Start draft</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{view === "review" ? <GmailReviewPage embedded /> : <>
|
{view === "review" ? <GmailReviewPage embedded /> : <>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
|
||||||
<TextField label="Search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" />
|
<TextField label="Search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" />
|
||||||
|
|||||||
Reference in New Issue
Block a user