feat(email): persist and resume reply drafts
This commit is contained in:
@@ -65,6 +65,7 @@ describe('CorrespondenceInboxPage', () => {
|
|||||||
attachmentCount: 1,
|
attachmentCount: 1,
|
||||||
},
|
},
|
||||||
] } as any);
|
] } as any);
|
||||||
|
if (url === '/email/drafts') return Promise.resolve({ data: [] } as any);
|
||||||
if (url === '/email/message') return Promise.resolve({ data: {
|
if (url === '/email/message') return Promise.resolve({ data: {
|
||||||
id: 'message-1', threadId: 'thread-1', subject: 'Interview invite', from: 'Maria Recruiter <maria@acme.test>', to: 'user@example.test',
|
id: 'message-1', threadId: 'thread-1', subject: 'Interview invite', from: 'Maria Recruiter <maria@acme.test>', to: 'user@example.test',
|
||||||
date: new Date().toISOString(), snippet: 'Interview', bodyText: 'Please choose an interview time.', labels: ['INBOX'], attachments: [{ fileName: 'agenda.pdf' }],
|
date: new Date().toISOString(), snippet: 'Interview', bodyText: 'Please choose an interview time.', labels: ['INBOX'], attachments: [{ fileName: 'agenda.pdf' }],
|
||||||
@@ -223,6 +224,69 @@ describe('CorrespondenceInboxPage', () => {
|
|||||||
expect(screen.getByRole('button', { name: /review and send/i })).toBeDisabled();
|
expect(screen.getByRole('button', { name: /review and send/i })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('saves an incomplete reply with its server delivery identity', 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 },
|
||||||
|
] } as any);
|
||||||
|
return original!(url, config);
|
||||||
|
});
|
||||||
|
mockedApi.post.mockImplementation((url: string) => {
|
||||||
|
if (url === '/email/drafts') return Promise.resolve({ data: {
|
||||||
|
id: 'draft-1', jobApplicationId: 42, provider: 'gmail', to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: '',
|
||||||
|
threadId: 'thread-1', 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();
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /view message/i }));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /reply with connected provider/i }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^save draft$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts', {
|
||||||
|
jobApplicationId: 42,
|
||||||
|
provider: 'gmail',
|
||||||
|
to: 'Maria Recruiter <maria@acme.test>',
|
||||||
|
subject: 'Re: Interview invite',
|
||||||
|
bodyText: '',
|
||||||
|
threadId: 'thread-1',
|
||||||
|
}));
|
||||||
|
expect(await screen.findByText(/email draft saved/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /^save changes$/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resumes a saved draft and surfaces revision conflicts without overwriting it', async () => {
|
||||||
|
const original = mockedApi.get.getMockImplementation();
|
||||||
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||||
|
if (url === '/email/drafts') return Promise.resolve({ data: [{
|
||||||
|
id: 'draft-1', jobApplicationId: 42, provider: 'gmail', to: 'maria@acme.test', subject: 'Saved subject', bodyText: 'Saved private draft.',
|
||||||
|
threadId: 'thread-1', clientRequestId: 'server-request-id', revision: 4, createdAtUtc: new Date().toISOString(), updatedAtUtc: new Date().toISOString(),
|
||||||
|
}] } as any);
|
||||||
|
if (url === '/email/providers') return Promise.resolve({ data: [
|
||||||
|
{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true },
|
||||||
|
] } as any);
|
||||||
|
return original!(url, config);
|
||||||
|
});
|
||||||
|
mockedApi.put.mockRejectedValue({ response: { status: 409 } });
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /resume saved subject/i }));
|
||||||
|
expect(screen.getByLabelText(/message/i)).toHaveValue('Saved private draft.');
|
||||||
|
fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Conflicting local edit.' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^save changes$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/email/drafts/draft-1', {
|
||||||
|
revision: 4,
|
||||||
|
to: 'maria@acme.test',
|
||||||
|
subject: 'Saved subject',
|
||||||
|
bodyText: 'Conflicting local edit.',
|
||||||
|
}));
|
||||||
|
expect(await screen.findByText(/changed in another tab/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/message/i)).toHaveValue('Conflicting local edit.');
|
||||||
|
});
|
||||||
|
|
||||||
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) => {
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ type EmailMessageDetail = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type EmailDraft = {
|
type EmailDraft = {
|
||||||
|
id?: string;
|
||||||
|
revision?: number;
|
||||||
jobApplicationId: number;
|
jobApplicationId: number;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
jobTitle: string;
|
jobTitle: string;
|
||||||
@@ -84,6 +86,13 @@ type EmailDraft = {
|
|||||||
clientRequestId: string;
|
clientRequestId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StoredEmailDraft = Pick<EmailDraft, "id" | "revision" | "jobApplicationId" | "provider" | "to" | "subject" | "bodyText" | "threadId" | "clientRequestId"> & {
|
||||||
|
id: string;
|
||||||
|
revision: number;
|
||||||
|
createdAtUtc: string;
|
||||||
|
updatedAtUtc: string;
|
||||||
|
};
|
||||||
|
|
||||||
type EmailSendResult = {
|
type EmailSendResult = {
|
||||||
attemptId: string;
|
attemptId: string;
|
||||||
status: "pending" | "sending" | "sent" | "failed" | "uncertain";
|
status: "pending" | "sending" | "sent" | "failed" | "uncertain";
|
||||||
@@ -113,6 +122,9 @@ export default function CorrespondenceInboxPage() {
|
|||||||
const [direction, setDirection] = useState<string>("all");
|
const [direction, setDirection] = useState<string>("all");
|
||||||
const [linkState, setLinkState] = useState<string>("all");
|
const [linkState, setLinkState] = useState<string>("all");
|
||||||
const [draft, setDraft] = useState<EmailDraft | null>(null);
|
const [draft, setDraft] = useState<EmailDraft | null>(null);
|
||||||
|
const [storedDrafts, setStoredDrafts] = useState<StoredEmailDraft[]>([]);
|
||||||
|
const [savingDraft, setSavingDraft] = useState(false);
|
||||||
|
const [draftError, setDraftError] = useState<string | null>(null);
|
||||||
const [sendResult, setSendResult] = useState<EmailSendResult | null>(null);
|
const [sendResult, setSendResult] = useState<EmailSendResult | null>(null);
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
@@ -152,6 +164,19 @@ export default function CorrespondenceInboxPage() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadStoredDrafts = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get<StoredEmailDraft[]>("/email/drafts");
|
||||||
|
setStoredDrafts(Array.isArray(response.data) ? response.data : []);
|
||||||
|
} catch {
|
||||||
|
setDraftError("Saved drafts could not be loaded. New email can still be reviewed before sending.");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadStoredDrafts();
|
||||||
|
}, [loadStoredDrafts]);
|
||||||
|
|
||||||
const filteredSummary = useMemo(() => {
|
const filteredSummary = useMemo(() => {
|
||||||
const linked = items.filter((item) => item.externalThreadId).length;
|
const linked = items.filter((item) => item.externalThreadId).length;
|
||||||
const inbound = items.filter((item) => item.direction === "inbound").length;
|
const inbound = items.filter((item) => item.direction === "inbound").length;
|
||||||
@@ -224,6 +249,28 @@ export default function CorrespondenceInboxPage() {
|
|||||||
});
|
});
|
||||||
setSendResult(null);
|
setSendResult(null);
|
||||||
setSendError(null);
|
setSendError(null);
|
||||||
|
setDraftError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumeDraft = async (stored: StoredEmailDraft) => {
|
||||||
|
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;
|
||||||
|
const provider = providers.find((candidate) => candidate.provider === stored.provider);
|
||||||
|
const item = items.find((candidate) => candidate.jobApplicationId === stored.jobApplicationId);
|
||||||
|
setDraft({
|
||||||
|
...stored,
|
||||||
|
companyName: item?.companyName || `Job #${stored.jobApplicationId}`,
|
||||||
|
jobTitle: item?.jobTitle || "Saved email draft",
|
||||||
|
providerName: provider?.displayName || stored.provider,
|
||||||
|
fromAddress: provider?.address || "Reconnect before sending",
|
||||||
|
});
|
||||||
|
setSendResult(null);
|
||||||
|
setSendError(null);
|
||||||
|
setDraftError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateDraft = (changes: Partial<Pick<EmailDraft, "to" | "subject" | "bodyText">>) => {
|
const updateDraft = (changes: Partial<Pick<EmailDraft, "to" | "subject" | "bodyText">>) => {
|
||||||
@@ -243,9 +290,51 @@ export default function CorrespondenceInboxPage() {
|
|||||||
confirmLabel: "Discard draft",
|
confirmLabel: "Discard draft",
|
||||||
destructive: true,
|
destructive: true,
|
||||||
}))) return;
|
}))) return;
|
||||||
|
if (draft?.id && draft.revision) {
|
||||||
|
try {
|
||||||
|
await api.delete(`/email/drafts/${draft.id}`, { params: { revision: draft.revision } });
|
||||||
|
await loadStoredDrafts();
|
||||||
|
} catch (error) {
|
||||||
|
setDraftError(getApiErrorMessage(error, "The saved draft changed elsewhere. Reload it before discarding."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
setSendResult(null);
|
setSendResult(null);
|
||||||
setSendError(null);
|
setSendError(null);
|
||||||
|
setDraftError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveDraft = async () => {
|
||||||
|
if (!draft || savingDraft || sendResult) return;
|
||||||
|
setSavingDraft(true);
|
||||||
|
setDraftError(null);
|
||||||
|
try {
|
||||||
|
const response = draft.id && draft.revision
|
||||||
|
? await api.put<StoredEmailDraft>(`/email/drafts/${draft.id}`, {
|
||||||
|
revision: draft.revision,
|
||||||
|
to: draft.to,
|
||||||
|
subject: draft.subject,
|
||||||
|
bodyText: draft.bodyText,
|
||||||
|
})
|
||||||
|
: await api.post<StoredEmailDraft>("/email/drafts", {
|
||||||
|
jobApplicationId: draft.jobApplicationId,
|
||||||
|
provider: draft.provider,
|
||||||
|
to: draft.to,
|
||||||
|
subject: draft.subject,
|
||||||
|
bodyText: draft.bodyText,
|
||||||
|
threadId: draft.threadId || null,
|
||||||
|
});
|
||||||
|
setDraft((current) => current ? { ...current, ...response.data } : null);
|
||||||
|
await loadStoredDrafts();
|
||||||
|
toast("Email draft saved.", "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
setDraftError(error?.response?.status === 409
|
||||||
|
? "This draft changed in another tab. Resume the latest saved version before editing again."
|
||||||
|
: getApiErrorMessage(error, "Failed to save the email draft."));
|
||||||
|
} finally {
|
||||||
|
setSavingDraft(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendDraft = async () => {
|
const sendDraft = async () => {
|
||||||
@@ -273,6 +362,15 @@ export default function CorrespondenceInboxPage() {
|
|||||||
setSendResult(response.data);
|
setSendResult(response.data);
|
||||||
if (response.data.status === "sent") {
|
if (response.data.status === "sent") {
|
||||||
toast("Email sent and saved to this job.", "success");
|
toast("Email sent and saved to this job.", "success");
|
||||||
|
if (draft.id && draft.revision) {
|
||||||
|
try {
|
||||||
|
await api.delete(`/email/drafts/${draft.id}`, { params: { revision: draft.revision } });
|
||||||
|
setDraft((current) => current ? { ...current, id: undefined, revision: undefined } : null);
|
||||||
|
await loadStoredDrafts();
|
||||||
|
} catch {
|
||||||
|
toast("Email was sent, but a newer saved draft still exists. Review it before taking another action.", "warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -367,6 +465,20 @@ 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}
|
||||||
|
{draftError && !draft ? <Alert severity="warning" sx={{ mb: 2 }}>{draftError}</Alert> : null}
|
||||||
|
|
||||||
|
{storedDrafts.length > 0 ? (
|
||||||
|
<Alert severity="info" sx={{ mb: 2 }}>
|
||||||
|
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap" alignItems="center">
|
||||||
|
<Typography variant="body2">{storedDrafts.length} saved email draft{storedDrafts.length === 1 ? "" : "s"} available.</Typography>
|
||||||
|
{storedDrafts.slice(0, 5).map((stored) => (
|
||||||
|
<Button key={stored.id} size="small" onClick={() => void resumeDraft(stored)}>
|
||||||
|
Resume {stored.subject || `job #${stored.jobApplicationId}`}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
) : 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 }}>
|
||||||
@@ -416,8 +528,12 @@ export default function CorrespondenceInboxPage() {
|
|||||||
The provider confirmed that this attempt did not complete. Review the connection and draft before creating a new attempt.
|
The provider confirmed that this attempt did not complete. Review the connection and draft before creating a new attempt.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
{draftError ? <Alert severity="warning" sx={{ mt: 1.5 }}>{draftError}</Alert> : null}
|
||||||
{sendError && sendResult?.status !== "uncertain" ? <Typography variant="body2" sx={{ color: "error.main", mt: 1 }}>{sendError}</Typography> : null}
|
{sendError && sendResult?.status !== "uncertain" ? <Typography variant="body2" sx={{ color: "error.main", mt: 1 }}>{sendError}</Typography> : null}
|
||||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1.5 }}>
|
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mt: 1.5, flexWrap: "wrap" }}>
|
||||||
|
<Button variant="outlined" onClick={() => void saveDraft()} disabled={savingDraft || sending || !!sendResult}>
|
||||||
|
{savingDraft ? "Saving…" : draft.id ? "Save changes" : "Save draft"}
|
||||||
|
</Button>
|
||||||
<Button variant="contained" onClick={() => void sendDraft()} disabled={sending || !!sendResult || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()}>
|
<Button variant="contained" onClick={() => void sendDraft()} disabled={sending || !!sendResult || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()}>
|
||||||
{sending ? "Sending…" : "Review and send"}
|
{sending ? "Sending…" : "Review and send"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user