fix(ui): preserve compose draft identity and delivery safeguards
This commit is contained in:
@@ -114,6 +114,8 @@ type EmailSendResult = {
|
||||
failureCategory?: string | null;
|
||||
};
|
||||
|
||||
type DraftMutationError = ViewResourceError & { action: "save" | "discard" | "prepare" };
|
||||
|
||||
function readError(error: any): ViewResourceError {
|
||||
const status = error?.response?.status as number | undefined;
|
||||
const unauthorized = status === 401 || status === 403;
|
||||
@@ -138,6 +140,11 @@ export default function CorrespondenceInboxPage() {
|
||||
const [jobs, setJobs] = useState<JobChoice[]>([]);
|
||||
const [jobsLoaded, setJobsLoaded] = useState(false);
|
||||
const [jobsLoading, setJobsLoading] = useState(false);
|
||||
const [jobsError, setJobsError] = useState<ViewResourceError | null>(null);
|
||||
const [loadedJobQuery, setLoadedJobQuery] = useState<string | null>(null);
|
||||
const jobsRequest = useRef(0);
|
||||
const pendingJobs = useRef<string | null>(null);
|
||||
const jobsPanel = useRef<HTMLDivElement>(null);
|
||||
const [jobQuery, setJobQuery] = useState("");
|
||||
const [composeSetupOpen, setComposeSetupOpen] = useState(false);
|
||||
const [composeJobId, setComposeJobId] = useState<number | "">("");
|
||||
@@ -171,13 +178,25 @@ export default function CorrespondenceInboxPage() {
|
||||
const hasCurrentInbox = loadedListKey === currentListKey && loadError?.kind !== "unauthorized";
|
||||
const [reviewVisited, setReviewVisited] = useState(view === "review");
|
||||
const mounted = useRef(true);
|
||||
const [draftsReadError, setDraftsReadError] = useState(false);
|
||||
const [draftsReadError, setDraftsReadError] = useState<ViewResourceError | null>(null);
|
||||
const [draftsLoading, setDraftsLoading] = useState(false);
|
||||
const draftsPending = useRef(false);
|
||||
const draftsRequest = useRef(0);
|
||||
const draftsPanel = useRef<HTMLDivElement>(null);
|
||||
const [draft, setDraft] = useState<EmailDraft | null>(null);
|
||||
const draftRef = useRef(draft);
|
||||
draftRef.current = draft;
|
||||
const draftBaseline = useRef<EmailDraft | null>(null);
|
||||
const draftPanel = useRef<HTMLDivElement>(null);
|
||||
const composeOperation = useRef(false);
|
||||
const [composeBusy, setComposeBusy] = useState(false);
|
||||
const latestT = useRef(t);
|
||||
latestT.current = t;
|
||||
const [storedDrafts, setStoredDrafts] = useState<StoredEmailDraft[]>([]);
|
||||
const [savingDraft, setSavingDraft] = useState(false);
|
||||
const [draftError, setDraftError] = useState<string | null>(null);
|
||||
const [draftError, setDraftError] = useState<DraftMutationError | null>(null);
|
||||
const [sendResult, setSendResult] = useState<EmailSendResult | null>(null);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [sendError, setSendError] = useState<ViewResourceError | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [unlinkingThreadId, setUnlinkingThreadId] = useState<string | null>(null);
|
||||
const unlinkPending = useRef(false);
|
||||
@@ -196,7 +215,7 @@ export default function CorrespondenceInboxPage() {
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => { mounted.current = false; listRequest.current += 1; detailRequest.current += 1; };
|
||||
return () => { mounted.current = false; listRequest.current += 1; detailRequest.current += 1; jobsRequest.current += 1; draftsRequest.current += 1; draftsPending.current = false; };
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -275,26 +294,47 @@ export default function CorrespondenceInboxPage() {
|
||||
}, []);
|
||||
useEffect(() => { void loadProviders(); }, [loadProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setJobsLoading(true);
|
||||
api.get<JobChoice[]>("/jobapplications/choices", { params: { q: jobQuery.trim() || undefined, limit: 20 } })
|
||||
.then((response) => setJobs(Array.isArray(response.data) ? response.data : []))
|
||||
.catch(() => setJobs([]))
|
||||
.finally(() => {
|
||||
setJobsLoaded(true);
|
||||
setJobsLoading(false);
|
||||
});
|
||||
}, jobQuery.trim() ? 250 : 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
const loadJobs = useCallback(async () => {
|
||||
const key = jobQuery.trim();
|
||||
if (pendingJobs.current === key) return;
|
||||
const request = ++jobsRequest.current;
|
||||
pendingJobs.current = key;
|
||||
setJobsLoading(true);
|
||||
setJobsError(null);
|
||||
try {
|
||||
const response = await api.get<JobChoice[]>("/jobapplications/choices", { params: { q: key || undefined, limit: 20 } });
|
||||
if (!mounted.current || request !== jobsRequest.current) return;
|
||||
setJobs(Array.isArray(response.data) ? response.data : []);
|
||||
setLoadedJobQuery(key);
|
||||
setJobsLoaded(true);
|
||||
} catch (error) {
|
||||
if (!mounted.current || request !== jobsRequest.current) return;
|
||||
setJobsError(readError(error));
|
||||
} finally {
|
||||
if (request === jobsRequest.current) { pendingJobs.current = null; if (mounted.current) setJobsLoading(false); }
|
||||
}
|
||||
}, [jobQuery]);
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void loadJobs(), jobQuery.trim() ? 250 : 0);
|
||||
return () => { window.clearTimeout(timer); jobsRequest.current += 1; pendingJobs.current = null; };
|
||||
}, [jobQuery, loadJobs]);
|
||||
|
||||
const loadStoredDrafts = useCallback(async () => {
|
||||
const loadStoredDrafts = useCallback(async (afterWrite = false) => {
|
||||
if (!mounted.current || (draftsPending.current && !afterWrite)) return;
|
||||
draftsPending.current = true;
|
||||
const request = ++draftsRequest.current;
|
||||
setDraftsLoading(true);
|
||||
try {
|
||||
const response = await api.get<StoredEmailDraft[]>("/email/drafts");
|
||||
if (mounted.current) { setStoredDrafts(Array.isArray(response.data) ? response.data : []); setDraftsReadError(false); }
|
||||
} catch {
|
||||
if (mounted.current) setDraftsReadError(true);
|
||||
if (mounted.current && request === draftsRequest.current) { setStoredDrafts(Array.isArray(response.data) ? response.data : []); setDraftsReadError(null); }
|
||||
} catch (error) {
|
||||
if (mounted.current && request === draftsRequest.current) {
|
||||
const failure = readError(error);
|
||||
setDraftsReadError(failure);
|
||||
if (failure.kind === "unauthorized") setStoredDrafts([]);
|
||||
}
|
||||
} finally {
|
||||
if (request === draftsRequest.current) { draftsPending.current = false; if (mounted.current) setDraftsLoading(false); }
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -356,225 +396,238 @@ export default function CorrespondenceInboxPage() {
|
||||
setDetailLoading(false);
|
||||
};
|
||||
|
||||
const startReply = async (item: CorrespondenceInboxItem) => {
|
||||
if (!messageDetail || !item.provider) return;
|
||||
const provider = providers.find((candidate) => candidate.provider === item.provider);
|
||||
if (!provider?.canSend) return;
|
||||
if (draft?.bodyText.trim() && !(await confirm({
|
||||
const hasDraftContent = (current: EmailDraft | null) => !!current && !!(current.to.trim() || current.subject.trim() || current.bodyText.trim());
|
||||
const draftDirty = !!draft && (!draftBaseline.current || draft.to !== draftBaseline.current.to || draft.subject !== draftBaseline.current.subject || draft.bodyText !== draftBaseline.current.bodyText);
|
||||
const jobsCurrent = loadedJobQuery === jobQuery.trim() && jobsError?.kind !== "unauthorized";
|
||||
const draftErrorMessage = draftError ? (draftError.status === 409 ? "" : draftError.message) || t(draftError.action === "discard"
|
||||
? draftError.status === 409 ? "emailInboxDraftChangedDiscard" : "emailInboxDraftDiscardFailed"
|
||||
: draftError.action === "prepare" ? draftError.status === 409 ? "emailInboxNewAttemptConflict" : "emailInboxNewAttemptFailed"
|
||||
: draftError.status === 409 ? "emailInboxDraftChangedElsewhere" : "emailInboxDraftSaveFailed") : "";
|
||||
const openDraft = (next: EmailDraft | null) => {
|
||||
draftRef.current = next;
|
||||
draftBaseline.current = next;
|
||||
setDraft(next);
|
||||
};
|
||||
const isCurrentDraft = (captured: EmailDraft) => mounted.current && draftRef.current?.clientRequestId === captured.clientRequestId
|
||||
&& draftRef.current?.id === captured.id && draftRef.current?.jobApplicationId === captured.jobApplicationId && draftRef.current?.provider === captured.provider;
|
||||
const finishComposeOperation = () => {
|
||||
composeOperation.current = false;
|
||||
if (mounted.current) { setComposeBusy(false); setSavingDraft(false); setSending(false); }
|
||||
};
|
||||
const replaceDraftApproved = () => {
|
||||
const current = draftRef.current;
|
||||
if (!hasDraftContent(current) && !sendResult) return null;
|
||||
const saved = !!current?.id && !draftDirty;
|
||||
const unresolved = sendResult && ["uncertain", "pending", "sending"].includes(sendResult.status);
|
||||
return confirm({
|
||||
title: t("emailInboxReplaceDraftTitle"),
|
||||
message: t("emailInboxReplaceReplyMessage"),
|
||||
message: (unresolved ? t("emailInboxDeliveryUncertain") + "\n\n" : "") + t(saved ? "emailInboxCurrentDraftSaved" : "emailInboxUnsavedDraftLost"),
|
||||
confirmLabel: t("emailInboxReplaceDraft"),
|
||||
destructive: true,
|
||||
}))) return;
|
||||
const subject = messageDetail.subject.trim();
|
||||
setDraft({
|
||||
jobApplicationId: item.jobApplicationId,
|
||||
companyName: item.companyName || t("emailInboxUnknownCompany"),
|
||||
jobTitle: item.jobTitle || t("emailInboxUnknownRole"),
|
||||
provider: item.provider,
|
||||
providerName: provider.displayName,
|
||||
fromAddress: provider.address || t("emailInboxConnectedAccount"),
|
||||
to: item.direction === "outbound" ? messageDetail.to : messageDetail.from,
|
||||
subject: /^re:/i.test(subject) ? subject : `Re: ${subject || t("emailInboxYourMessage")}`,
|
||||
bodyText: "",
|
||||
threadId: messageDetail.threadId || item.externalThreadId || "",
|
||||
clientRequestId: globalThis.crypto.randomUUID(),
|
||||
destructive: !saved,
|
||||
});
|
||||
setSendResult(null);
|
||||
setSendError(null);
|
||||
setDraftError(null);
|
||||
};
|
||||
|
||||
const startReply = async (item: CorrespondenceInboxItem) => {
|
||||
const detail = messageDetail;
|
||||
const provider = providers.find((candidate) => candidate.provider === item.provider);
|
||||
if (!detail || !item.provider || !provider?.canSend || composeOperation.current) return;
|
||||
composeOperation.current = true;
|
||||
try {
|
||||
const approval = replaceDraftApproved();
|
||||
if ((approval && !(await approval)) || !mounted.current) return;
|
||||
const subject = detail.subject.trim();
|
||||
openDraft({
|
||||
jobApplicationId: item.jobApplicationId,
|
||||
companyName: item.companyName || t("emailInboxUnknownCompany"),
|
||||
jobTitle: item.jobTitle || t("emailInboxUnknownRole"),
|
||||
provider: item.provider,
|
||||
providerName: provider.displayName,
|
||||
fromAddress: provider.address || t("emailInboxConnectedAccount"),
|
||||
to: item.direction === "outbound" ? detail.to : detail.from,
|
||||
subject: /^re:/i.test(subject) ? subject : `Re: ${subject || t("emailInboxYourMessage")}`,
|
||||
bodyText: "",
|
||||
threadId: detail.threadId || item.externalThreadId || "",
|
||||
clientRequestId: globalThis.crypto.randomUUID(),
|
||||
});
|
||||
setSendResult(null); setSendError(null); setDraftError(null);
|
||||
} finally { finishComposeOperation(); }
|
||||
};
|
||||
|
||||
const resumeDraft = async (stored: StoredEmailDraft) => {
|
||||
if (draft?.bodyText.trim() && !(await confirm({
|
||||
title: t("emailInboxReplaceDraftTitle"),
|
||||
message: draft.id ? t("emailInboxCurrentDraftSaved") : t("emailInboxUnsavedDraftLost"),
|
||||
confirmLabel: t("emailInboxReplaceDraft"),
|
||||
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 || t("emailInboxSavedDraft"),
|
||||
providerName: provider?.displayName || stored.provider,
|
||||
fromAddress: provider?.address || t("emailInboxReconnectBeforeSending"),
|
||||
});
|
||||
setSendResult(null);
|
||||
setSendError(null);
|
||||
setDraftError(null);
|
||||
if (composeOperation.current) return;
|
||||
composeOperation.current = true;
|
||||
try {
|
||||
const approval = replaceDraftApproved();
|
||||
if ((approval && !(await approval)) || !mounted.current) return;
|
||||
const provider = providers.find((candidate) => candidate.provider === stored.provider);
|
||||
const item = items.find((candidate) => candidate.jobApplicationId === stored.jobApplicationId);
|
||||
openDraft({
|
||||
...stored,
|
||||
companyName: item?.companyName || `${t("emailInboxJobNumber")} #${stored.jobApplicationId}`,
|
||||
jobTitle: item?.jobTitle || t("emailInboxSavedDraft"),
|
||||
providerName: provider?.displayName || stored.provider,
|
||||
fromAddress: provider?.address || t("emailInboxReconnectBeforeSending"),
|
||||
});
|
||||
setSendResult(null); setSendError(null); setDraftError(null);
|
||||
} finally { finishComposeOperation(); }
|
||||
};
|
||||
|
||||
const openComposeSetup = () => {
|
||||
if (composeOperation.current) return;
|
||||
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 job = jobsCurrent ? jobs.find((candidate) => candidate.id === composeJobId) : undefined;
|
||||
const provider = sendCapableProviders.find((candidate) => candidate.provider === composeProvider);
|
||||
if (!job || !provider) return;
|
||||
if (draft?.bodyText.trim() && !(await confirm({
|
||||
title: t("emailInboxReplaceDraftTitle"),
|
||||
message: draft.id ? t("emailInboxCurrentDraftSaved") : t("emailInboxUnsavedDraftLost"),
|
||||
confirmLabel: t("emailInboxReplaceDraft"),
|
||||
destructive: !draft.id,
|
||||
}))) return;
|
||||
setDraft({
|
||||
jobApplicationId: job.id,
|
||||
companyName: job.companyName || t("emailInboxUnknownCompany"),
|
||||
jobTitle: job.jobTitle || t("emailInboxUnknownRole"),
|
||||
provider: provider.provider,
|
||||
providerName: provider.displayName,
|
||||
fromAddress: provider.address || t("emailInboxConnectedAccount"),
|
||||
to: "",
|
||||
subject: "",
|
||||
bodyText: "",
|
||||
threadId: "",
|
||||
clientRequestId: globalThis.crypto.randomUUID(),
|
||||
});
|
||||
setComposeSetupOpen(false);
|
||||
setSendResult(null);
|
||||
setSendError(null);
|
||||
setDraftError(null);
|
||||
if (!job || !provider || jobsLoading || jobsError || composeOperation.current) return;
|
||||
composeOperation.current = true;
|
||||
try {
|
||||
const approval = replaceDraftApproved();
|
||||
if ((approval && !(await approval)) || !mounted.current) return;
|
||||
openDraft({
|
||||
jobApplicationId: job.id,
|
||||
companyName: job.companyName || t("emailInboxUnknownCompany"),
|
||||
jobTitle: job.jobTitle || t("emailInboxUnknownRole"),
|
||||
provider: provider.provider,
|
||||
providerName: provider.displayName,
|
||||
fromAddress: provider.address || t("emailInboxConnectedAccount"),
|
||||
to: "", subject: "", bodyText: "", threadId: "",
|
||||
clientRequestId: globalThis.crypto.randomUUID(),
|
||||
});
|
||||
setComposeSetupOpen(false);
|
||||
setSendResult(null); setSendError(null); setDraftError(null);
|
||||
} finally { finishComposeOperation(); }
|
||||
};
|
||||
|
||||
const updateDraft = (changes: Partial<Pick<EmailDraft, "to" | "subject" | "bodyText">>) => {
|
||||
setDraft((current) => current ? {
|
||||
...current,
|
||||
...changes,
|
||||
clientRequestId: sendResult ? globalThis.crypto.randomUUID() : current.clientRequestId,
|
||||
} : null);
|
||||
if (sendResult) setSendResult(null);
|
||||
const current = draftRef.current;
|
||||
if (!current || composeOperation.current || sendResult) return;
|
||||
const next = { ...current, ...changes };
|
||||
draftRef.current = next;
|
||||
setDraft(next);
|
||||
setSendError(null);
|
||||
};
|
||||
|
||||
const discardDraft = async () => {
|
||||
if (draft?.bodyText.trim() && !(await confirm({
|
||||
title: t("emailInboxDiscardDraftTitle"),
|
||||
message: t("emailInboxDiscardDraftMessage"),
|
||||
confirmLabel: t("emailInboxDiscardDraft"),
|
||||
destructive: true,
|
||||
}))) 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, t("emailInboxDraftChangedDiscard")));
|
||||
return;
|
||||
const captured = draftRef.current;
|
||||
if (!captured || composeOperation.current) return;
|
||||
composeOperation.current = true;
|
||||
try {
|
||||
if ((captured.id || hasDraftContent(captured) || sendResult) && !(await confirm({
|
||||
title: t("emailInboxDiscardDraftTitle"),
|
||||
message: (sendResult && ["uncertain", "pending", "sending"].includes(sendResult.status) ? t("emailInboxDeliveryUncertain") + "\n\n" : "") + t("emailInboxDiscardDraftMessage"),
|
||||
confirmLabel: t("emailInboxDiscardDraft"), destructive: true,
|
||||
}))) return;
|
||||
if (!isCurrentDraft(captured)) return;
|
||||
setComposeBusy(true);
|
||||
draftPanel.current?.focus({ preventScroll: true });
|
||||
setDraftError(null);
|
||||
if (captured.id && captured.revision) await api.delete(`/email/drafts/${captured.id}`, { params: { revision: captured.revision } });
|
||||
if (!isCurrentDraft(captured)) return;
|
||||
inboxPanel.current?.focus({ preventScroll: true });
|
||||
openDraft(null);
|
||||
setSendResult(null); setSendError(null);
|
||||
if (captured.id) {
|
||||
setStoredDrafts((current) => current.filter((stored) => stored.id !== captured.id));
|
||||
await loadStoredDrafts(true);
|
||||
}
|
||||
}
|
||||
setDraft(null);
|
||||
setSendResult(null);
|
||||
setSendError(null);
|
||||
setDraftError(null);
|
||||
} catch (error) {
|
||||
if (isCurrentDraft(captured)) setDraftError({ ...readError(error), action: "discard" });
|
||||
} finally { finishComposeOperation(); }
|
||||
};
|
||||
|
||||
const saveDraft = async () => {
|
||||
if (!draft || savingDraft || sendResult) return;
|
||||
setSavingDraft(true);
|
||||
setDraftError(null);
|
||||
const captured = draftRef.current;
|
||||
if (!captured || composeOperation.current || sendResult) return;
|
||||
composeOperation.current = true;
|
||||
setComposeBusy(true); setSavingDraft(true); setDraftError(null);
|
||||
draftPanel.current?.focus({ preventScroll: true });
|
||||
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(t("emailInboxDraftSaved"), "success");
|
||||
} catch (error: any) {
|
||||
setDraftError(error?.response?.status === 409
|
||||
? t("emailInboxDraftChangedElsewhere")
|
||||
: getApiErrorMessage(error, t("emailInboxDraftSaveFailed")));
|
||||
} finally {
|
||||
setSavingDraft(false);
|
||||
}
|
||||
const response = captured.id && captured.revision
|
||||
? await api.put<StoredEmailDraft>(`/email/drafts/${captured.id}`, { revision: captured.revision, to: captured.to, subject: captured.subject, bodyText: captured.bodyText })
|
||||
: await api.post<StoredEmailDraft>("/email/drafts", { jobApplicationId: captured.jobApplicationId, provider: captured.provider, to: captured.to, subject: captured.subject, bodyText: captured.bodyText, threadId: captured.threadId || null });
|
||||
if (!isCurrentDraft(captured)) return;
|
||||
openDraft({ ...captured, ...response.data });
|
||||
setStoredDrafts((current) => [response.data, ...current.filter((stored) => stored.id !== response.data.id)]);
|
||||
await loadStoredDrafts(true);
|
||||
if (mounted.current) toast(latestT.current("emailInboxDraftSaved"), "success");
|
||||
} catch (error) {
|
||||
if (isCurrentDraft(captured)) setDraftError({ ...readError(error), action: "save" });
|
||||
} finally { finishComposeOperation(); }
|
||||
};
|
||||
|
||||
const sendDraft = async () => {
|
||||
if (!draft || sending || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()) return;
|
||||
const approved = await confirm({
|
||||
title: t("emailInboxConfirmSendTitle"),
|
||||
message: t("emailInboxConfirmSendMessage", { provider: draft.providerName, from: draft.fromAddress, to: draft.to.trim(), subject: draft.subject.trim(), thread: draft.threadId || t("emailInboxNewMessage") }),
|
||||
confirmLabel: t("emailInboxSendEmail"),
|
||||
});
|
||||
if (!approved) return;
|
||||
|
||||
setSending(true);
|
||||
setSendError(null);
|
||||
const captured = draftRef.current;
|
||||
if (!captured || composeOperation.current || sendResult || !captured.to.trim() || !captured.subject.trim() || !captured.bodyText.trim()) return;
|
||||
composeOperation.current = true;
|
||||
try {
|
||||
if (!(await confirm({
|
||||
title: t("emailInboxConfirmSendTitle"),
|
||||
message: t("emailInboxConfirmSendMessage", { provider: captured.providerName, from: captured.fromAddress, to: captured.to.trim(), subject: captured.subject.trim(), thread: captured.threadId || t("emailInboxNewMessage") }),
|
||||
confirmLabel: t("emailInboxSendEmail"),
|
||||
})) || !isCurrentDraft(captured)) return;
|
||||
setComposeBusy(true); setSending(true); setSendError(null);
|
||||
draftPanel.current?.focus({ preventScroll: true });
|
||||
const response = await api.post<EmailSendResult>("/email/send", {
|
||||
jobApplicationId: draft.jobApplicationId,
|
||||
provider: draft.provider,
|
||||
clientRequestId: draft.clientRequestId,
|
||||
to: draft.to.trim(),
|
||||
subject: draft.subject.trim(),
|
||||
bodyText: draft.bodyText,
|
||||
threadId: draft.threadId || null,
|
||||
confirmed: true,
|
||||
jobApplicationId: captured.jobApplicationId, provider: captured.provider, clientRequestId: captured.clientRequestId,
|
||||
to: captured.to.trim(), subject: captured.subject.trim(), bodyText: captured.bodyText, threadId: captured.threadId || null, confirmed: true,
|
||||
});
|
||||
if (!isCurrentDraft(captured)) return;
|
||||
setSendResult(response.data);
|
||||
if (response.data.status === "sent") {
|
||||
toast(t("emailInboxSentSaved"), "success");
|
||||
if (draft.id && draft.revision) {
|
||||
toast(latestT.current("emailInboxSentSaved"), "success");
|
||||
if (captured.id && captured.revision) {
|
||||
try {
|
||||
await api.delete(`/email/drafts/${draft.id}`, { params: { revision: draft.revision } });
|
||||
setDraft((current) => current ? { ...current, id: undefined, revision: undefined } : null);
|
||||
await loadStoredDrafts();
|
||||
await api.delete(`/email/drafts/${captured.id}`, { params: { revision: captured.revision } });
|
||||
if (!isCurrentDraft(captured)) return;
|
||||
openDraft({ ...captured, id: undefined, revision: undefined });
|
||||
setStoredDrafts((current) => current.filter((stored) => stored.id !== captured.id));
|
||||
} catch {
|
||||
toast(t("emailInboxSentNewerDraft"), "warning");
|
||||
if (!mounted.current) return;
|
||||
toast(latestT.current("emailInboxSentNewerDraft"), "warning");
|
||||
}
|
||||
if (mounted.current) await loadStoredDrafts(true);
|
||||
}
|
||||
await load();
|
||||
if (mounted.current) await latestLoad.current();
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!isCurrentDraft(captured)) return;
|
||||
const result = error?.response?.data;
|
||||
if (["failed", "uncertain", "sent", "pending", "sending"].includes(result?.status)) {
|
||||
setSendResult(result as EmailSendResult);
|
||||
} else if (!error?.response || error.response.status >= 500) {
|
||||
setSendResult({ attemptId: draft.clientRequestId, status: "uncertain", duplicate: false, failureCategory: "client_connection_lost" });
|
||||
} else {
|
||||
setSendResult({ attemptId: draft.clientRequestId, status: "failed", duplicate: false });
|
||||
}
|
||||
setSendError(getApiErrorMessage(error, t("emailInboxSendFailed")));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
if (["failed", "uncertain", "sent", "pending", "sending"].includes(result?.status)) setSendResult(result as EmailSendResult);
|
||||
else if (!error?.response || error.response.status >= 500 || error.response.status === 409)
|
||||
setSendResult({ attemptId: captured.clientRequestId, status: "uncertain", duplicate: false, failureCategory: "client_connection_lost" });
|
||||
setSendError(readError(error));
|
||||
} finally { finishComposeOperation(); }
|
||||
};
|
||||
|
||||
const prepareNewAttempt = async () => {
|
||||
if (!draft || sendResult?.status !== "failed") return;
|
||||
setDraftError(null);
|
||||
if (draft.id && draft.revision) {
|
||||
try {
|
||||
const response = await api.post<StoredEmailDraft>(`/email/drafts/${draft.id}/new-attempt`, { revision: draft.revision });
|
||||
setDraft((current) => current ? { ...current, ...response.data } : null);
|
||||
await loadStoredDrafts();
|
||||
} catch (error: any) {
|
||||
setDraftError(error?.response?.status === 409
|
||||
? t("emailInboxNewAttemptConflict")
|
||||
: getApiErrorMessage(error, t("emailInboxNewAttemptFailed")));
|
||||
return;
|
||||
const captured = draftRef.current;
|
||||
if (!captured || composeOperation.current || sendResult?.status !== "failed") return;
|
||||
composeOperation.current = true;
|
||||
setComposeBusy(true); setDraftError(null);
|
||||
draftPanel.current?.focus({ preventScroll: true });
|
||||
try {
|
||||
if (captured.id && captured.revision) {
|
||||
const response = await api.post<StoredEmailDraft>(`/email/drafts/${captured.id}/new-attempt`, { revision: captured.revision });
|
||||
if (!isCurrentDraft(captured)) return;
|
||||
const stored = { ...captured, ...response.data };
|
||||
openDraft({ ...stored, to: captured.to, subject: captured.subject, bodyText: captured.bodyText });
|
||||
draftBaseline.current = stored;
|
||||
setStoredDrafts((current) => [response.data, ...current.filter((item) => item.id !== response.data.id)]);
|
||||
} else {
|
||||
if (!isCurrentDraft(captured)) return;
|
||||
const next = { ...captured, clientRequestId: globalThis.crypto.randomUUID() };
|
||||
draftRef.current = next;
|
||||
setDraft(next);
|
||||
}
|
||||
} else {
|
||||
setDraft((current) => current ? { ...current, clientRequestId: globalThis.crypto.randomUUID() } : null);
|
||||
}
|
||||
setSendResult(null);
|
||||
setSendError(null);
|
||||
setSendResult(null); setSendError(null);
|
||||
if (captured.id) await loadStoredDrafts(true);
|
||||
} catch (error) {
|
||||
if (isCurrentDraft(captured)) setDraftError({ ...readError(error), action: "prepare" });
|
||||
} finally { finishComposeOperation(); }
|
||||
};
|
||||
|
||||
const unlinkGmailThread = async (item: CorrespondenceInboxItem) => {
|
||||
@@ -626,9 +679,9 @@ export default function CorrespondenceInboxPage() {
|
||||
{view === "inbox" && hasCurrentInbox ? <Chip icon={<MailOutlineIcon />} label={t("emailInboxItems", { count: inboxTotal })} variant="outlined" /> : null}
|
||||
{view === "inbox" && hasCurrentInbox ? <Chip label={t("emailInboxLinkedOnPage", { count: filteredSummary.linked })} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> : null}
|
||||
{view === "inbox" && hasCurrentInbox ? <Chip label={t("emailInboxInboundOnPage", { count: filteredSummary.inbound })} variant="outlined" /> : null}
|
||||
<Button variant={view === "inbox" ? "outlined" : "text"} aria-pressed={view === "inbox"} disabled={!!unlinkingThreadId} size="small" onClick={() => updateFilters({ view: "inbox" })}>{t("emailInboxLinkedMessages")}</Button>
|
||||
<Button variant={view === "review" ? "outlined" : "text"} aria-pressed={view === "review"} disabled={!!unlinkingThreadId} size="small" onClick={() => updateFilters({ view: "review" })}>{t("emailInboxReviewSuggestions")}</Button>
|
||||
<Button variant="outlined" size="small" onClick={openComposeSetup} disabled={jobs.length === 0 || sendCapableProviders.length === 0}>{t("emailInboxCompose")}</Button>
|
||||
<Button variant={view === "inbox" ? "outlined" : "text"} aria-pressed={view === "inbox"} disabled={!!unlinkingThreadId || composeBusy} size="small" onClick={() => updateFilters({ view: "inbox" })}>{t("emailInboxLinkedMessages")}</Button>
|
||||
<Button variant={view === "review" ? "outlined" : "text"} aria-pressed={view === "review"} disabled={!!unlinkingThreadId || composeBusy} size="small" onClick={() => updateFilters({ view: "review" })}>{t("emailInboxReviewSuggestions")}</Button>
|
||||
<Button variant="outlined" size="small" onClick={openComposeSetup} disabled={composeBusy || jobsLoading || !jobsCurrent || jobs.length === 0 || sendCapableProviders.length === 0}>{t("emailInboxCompose")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -646,17 +699,21 @@ export default function CorrespondenceInboxPage() {
|
||||
</Box>
|
||||
<ViewStateNotice compact loading={providersLoading} title={t("emailInboxProviderStatusUnavailable")} error={providerError ? { ...providerError, message: providerError.message || t("emailInboxProviderStatusUnavailable") } : null} onRetry={() => { providerPanel.current?.focus({ preventScroll: true }); return loadProviders(); }} />
|
||||
</Box>
|
||||
{jobsLoaded && jobs.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("emailInboxAddJobFirst")}</Alert> : null}
|
||||
<Box ref={jobsPanel} role="group" aria-label={t("emailInboxJob")} tabIndex={-1}>
|
||||
<ViewStateNotice compact loading={jobsLoading} title={t("emailInboxJobsLoadFailed")} error={jobsError ? { ...jobsError, message: jobsError.message || t("emailInboxJobsLoadFailed") } : null} onRetry={() => { jobsPanel.current?.focus({ preventScroll: true }); return loadJobs(); }} />
|
||||
</Box>
|
||||
{jobsLoaded && jobsCurrent && !jobsLoading && !jobsError && !jobQuery.trim() && jobs.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("emailInboxAddJobFirst")}</Alert> : null}
|
||||
{providerStatusLoaded && !providerStatusError && sendCapableProviders.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("emailInboxReconnectToCompose")}</Alert> : null}
|
||||
{draftError && !draft ? <Alert severity="warning" sx={{ mb: 2 }}>{draftError}</Alert> : null}
|
||||
{draftsReadError && !draft ? <Alert severity="warning" sx={{ mb: 2 }}>{t("emailInboxDraftsLoadFailed")}</Alert> : null}
|
||||
<Box ref={draftsPanel} role="group" aria-label={t("emailInboxSavedDraft")} tabIndex={-1}>
|
||||
<ViewStateNotice compact loading={draftsLoading} title={t("emailInboxDraftsLoadFailed")} error={draftsReadError ? { ...draftsReadError, message: draftsReadError.message || t("emailInboxDraftsLoadFailed") } : null} onRetry={() => { draftsPanel.current?.focus({ preventScroll: true }); return loadStoredDrafts(); }} />
|
||||
</Box>
|
||||
|
||||
{storedDrafts.length > 0 ? (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap" alignItems="center">
|
||||
<Typography variant="body2">{t(storedDrafts.length === 1 ? "emailInboxSavedDraftOne" : "emailInboxSavedDraftMany", { count: storedDrafts.length })}</Typography>
|
||||
{storedDrafts.slice(0, 5).map((stored) => (
|
||||
<Button key={stored.id} size="small" onClick={() => void resumeDraft(stored)}>
|
||||
<Button key={stored.id} size="small" disabled={composeBusy} onClick={() => void resumeDraft(stored)}>
|
||||
{t("emailInboxResumeDraft", { name: stored.subject || `${t("emailInboxJobNumber")} #${stored.jobApplicationId}` })}
|
||||
</Button>
|
||||
))}
|
||||
@@ -665,12 +722,14 @@ export default function CorrespondenceInboxPage() {
|
||||
) : 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 }}>{t("emailInboxCompose")}</Typography>
|
||||
<Paper component="section" aria-labelledby="compose-new-title" variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, mb: 2 }}>
|
||||
<Typography id="compose-new-title" component="h3" variant="h6" sx={{ mb: 1.5 }}>{t("emailInboxCompose")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr auto" }, gap: 1.25, alignItems: "center" }}>
|
||||
<Autocomplete
|
||||
options={jobs}
|
||||
options={jobsCurrent ? jobs : []}
|
||||
loading={jobsLoading}
|
||||
disabled={composeBusy}
|
||||
noOptionsText={t("emailInboxNoJobMatches")}
|
||||
value={jobs.find((job) => job.id === composeJobId) ?? null}
|
||||
onInputChange={(_, value, reason) => {
|
||||
if (reason === "input" || reason === "clear") setJobQuery(value);
|
||||
@@ -682,13 +741,13 @@ export default function CorrespondenceInboxPage() {
|
||||
/>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="compose-provider-label">{t("emailInboxSendingProvider")}</InputLabel>
|
||||
<Select labelId="compose-provider-label" value={composeProvider} label={t("emailInboxSendingProvider")} onChange={(event) => setComposeProvider(String(event.target.value))}>
|
||||
<Select labelId="compose-provider-label" value={composeProvider} disabled={composeBusy} label={t("emailInboxSendingProvider")} onChange={(event) => setComposeProvider(String(event.target.value))}>
|
||||
{sendCapableProviders.map((provider) => <MenuItem key={provider.provider} value={provider.provider}>{provider.displayName} · {provider.address || t("emailInboxConnectedAccount")}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button onClick={() => setComposeSetupOpen(false)}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={() => void startNewMessage()} disabled={!composeJobId || !composeProvider}>{t("emailInboxStartDraft")}</Button>
|
||||
<Button disabled={composeBusy} onClick={() => setComposeSetupOpen(false)}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={() => void startNewMessage()} disabled={composeBusy || jobsLoading || !!jobsError || !jobsCurrent || !jobs.some((job) => job.id === composeJobId) || !composeProvider}>{t("emailInboxStartDraft")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
@@ -719,37 +778,38 @@ export default function CorrespondenceInboxPage() {
|
||||
</Box>
|
||||
|
||||
{draft ? (
|
||||
<Paper component="section" aria-labelledby="email-draft-title" variant="outlined" sx={{ p: 2, borderRadius: 3, mb: 2 }}>
|
||||
<Paper ref={draftPanel} tabIndex={-1} component="section" aria-labelledby="email-draft-title" variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, mb: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography id="email-draft-title" component="h2" variant="h6" sx={{ fontWeight: 900 }}>{t("emailInboxDraftTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{draft.companyName} · {draft.jobTitle}</Typography>
|
||||
<Typography id="email-draft-title" component="h3" variant="h6">{t("emailInboxDraftTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{draft.companyName} · {draft.jobTitle}</Typography>
|
||||
{draftDirty ? <Chip size="small" variant="outlined" color="warning" label={t("assetsUnsavedChanges")} sx={{ mt: 1 }} /> : null}
|
||||
</Box>
|
||||
<Button onClick={() => void discardDraft()} disabled={sending}>{t("emailInboxDiscardDraft")}</Button>
|
||||
<Button onClick={() => void discardDraft()} disabled={composeBusy}>{t("emailInboxDiscardDraft")}</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.25 }}>
|
||||
<TextField label={t("emailInboxProvider")} value={`${draft.providerName} · ${draft.fromAddress}`} slotProps={{ input: { readOnly: true } }} />
|
||||
<TextField label={t("emailInboxThread")} value={draft.threadId || t("emailInboxNewMessage")} slotProps={{ input: { readOnly: true } }} />
|
||||
<TextField label={t("emailInboxRecipient")} type="email" value={draft.to} onChange={(event) => updateDraft({ to: event.target.value })} disabled={sending} inputProps={{ maxLength: 320 }} />
|
||||
<TextField label={t("emailInboxSubject")} value={draft.subject} onChange={(event) => updateDraft({ subject: event.target.value })} disabled={sending} inputProps={{ maxLength: 998 }} />
|
||||
<TextField label={t("emailInboxMessage")} value={draft.bodyText} onChange={(event) => updateDraft({ bodyText: event.target.value })} disabled={sending} multiline minRows={6} inputProps={{ maxLength: 200000 }} helperText={t("emailInboxCharacters", { count: draft.bodyText.length })} sx={{ gridColumn: "1 / -1" }} />
|
||||
<TextField label={t("emailInboxRecipient")} type="email" value={draft.to} onChange={(event) => updateDraft({ to: event.target.value })} disabled={composeBusy || !!sendResult} inputProps={{ maxLength: 320 }} />
|
||||
<TextField label={t("emailInboxSubject")} value={draft.subject} onChange={(event) => updateDraft({ subject: event.target.value })} disabled={composeBusy || !!sendResult} inputProps={{ maxLength: 998 }} />
|
||||
<TextField label={t("emailInboxMessage")} value={draft.bodyText} onChange={(event) => updateDraft({ bodyText: event.target.value })} disabled={composeBusy || !!sendResult} multiline minRows={6} inputProps={{ maxLength: 200000 }} helperText={t("emailInboxCharacters", { count: draft.bodyText.length })} sx={{ gridColumn: "1 / -1", "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }} />
|
||||
</Box>
|
||||
{sendResult?.status === "sent" ? <Alert severity="success" sx={{ mt: 1.5 }}>{t("emailInboxSentSuccess")}</Alert> : null}
|
||||
{sendResult?.status === "uncertain" || sendResult?.status === "sending" || sendResult?.status === "pending" ? (
|
||||
<Alert severity="warning" sx={{ mt: 1.5 }}>{t("emailInboxDeliveryUncertain")}</Alert>
|
||||
) : null}
|
||||
{sendResult?.status === "failed" ? (
|
||||
<Alert severity="error" sx={{ mt: 1.5 }} action={<Button color="inherit" size="small" onClick={() => void prepareNewAttempt()}>{t("emailInboxPrepareAttempt")}</Button>}>
|
||||
<Alert severity="error" sx={{ mt: 1.5 }} action={<Button color="inherit" size="small" disabled={composeBusy} onClick={() => void prepareNewAttempt()}>{t("emailInboxPrepareAttempt")}</Button>}>
|
||||
{t("emailInboxDeliveryFailed")}
|
||||
</Alert>
|
||||
) : 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}
|
||||
{draftError ? <Alert severity="warning" sx={{ mt: 1.5 }}>{draftErrorMessage}</Alert> : null}
|
||||
{sendError && sendResult?.status !== "uncertain" ? <Typography role="alert" variant="body2" sx={{ color: "error.main", mt: 1 }}>{sendError.message || t("emailInboxSendFailed")}</Typography> : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mt: 1.5, flexWrap: "wrap" }}>
|
||||
<Button variant="outlined" onClick={() => void saveDraft()} disabled={savingDraft || sending || !!sendResult}>
|
||||
<Button variant="outlined" onClick={() => void saveDraft()} disabled={composeBusy || !!sendResult}>
|
||||
{savingDraft ? t("saving") : draft.id ? t("profileSaveChanges") : t("emailInboxSaveDraft")}
|
||||
</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={composeBusy || !!sendResult || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()}>
|
||||
{sending ? t("emailInboxSending") : t("emailInboxReviewSend")}
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -802,7 +862,7 @@ export default function CorrespondenceInboxPage() {
|
||||
{messageDetail.attachments.map((attachment, index) => <Chip key={`${attachment.fileName || "attachment"}-${index}`} size="small" label={attachment.fileName || t("emailInboxAttachment")} variant="outlined" />)}
|
||||
</Box> : null}
|
||||
{item.provider && providers.find((provider) => provider.provider === item.provider)?.canSend ? (
|
||||
<Button sx={{ mt: 1.5 }} variant="outlined" onClick={() => void startReply(item)}>{t("emailInboxReply")}</Button>
|
||||
<Button sx={{ mt: 1.5 }} variant="outlined" disabled={composeBusy} onClick={() => void startReply(item)}>{t("emailInboxReply")}</Button>
|
||||
) : item.provider && item.provider !== "manual" ? (
|
||||
<Alert severity="info" sx={{ mt: 1.5 }}>{t("emailInboxReconnectToReply")}</Alert>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user