feat(email): add confirmed reply composer
Keep provider, recipient, subject, thread, and body editable until an app-owned confirmation is accepted. Reuse one request UUID per attempt and block automatic retry when delivery is uncertain.
This commit is contained in:
@@ -3,6 +3,7 @@ import '@testing-library/jest-dom';
|
|||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom';
|
import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import { ToastProvider } from './toast';
|
import { ToastProvider } from './toast';
|
||||||
|
import { ConfirmProvider } from './confirm';
|
||||||
import { I18nProvider } from './i18n/I18nProvider';
|
import { I18nProvider } from './i18n/I18nProvider';
|
||||||
import CorrespondenceInboxPage from './views/CorrespondenceInboxPage';
|
import CorrespondenceInboxPage from './views/CorrespondenceInboxPage';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
@@ -24,17 +25,20 @@ const mockedApi = api as jest.Mocked<typeof api>;
|
|||||||
function renderPage() {
|
function renderPage() {
|
||||||
return render(
|
return render(
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<I18nProvider>
|
<ConfirmProvider>
|
||||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<I18nProvider>
|
||||||
<CorrespondenceInboxPage />
|
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
</MemoryRouter>
|
<CorrespondenceInboxPage />
|
||||||
</I18nProvider>
|
</MemoryRouter>
|
||||||
|
</I18nProvider>
|
||||||
|
</ConfirmProvider>
|
||||||
</ToastProvider>,
|
</ToastProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('CorrespondenceInboxPage', () => {
|
describe('CorrespondenceInboxPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(globalThis.crypto, 'randomUUID', { configurable: true, value: jest.fn(() => '00000000-0000-4000-8000-000000000123') });
|
||||||
mockedApi.get.mockImplementation((url: string) => {
|
mockedApi.get.mockImplementation((url: string) => {
|
||||||
if (url === '/email/providers') return Promise.resolve({ data: [
|
if (url === '/email/providers') return Promise.resolve({ data: [
|
||||||
{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: false },
|
{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: false },
|
||||||
@@ -135,6 +139,80 @@ describe('CorrespondenceInboxPage', () => {
|
|||||||
expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/message/1');
|
expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/message/1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps replies editable and requires final send confirmation', 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.mockResolvedValue({ data: {
|
||||||
|
attemptId: 'attempt-1', status: 'sent', duplicate: false, externalMessageId: 'sent-1', externalThreadId: 'thread-1', failureCategory: null,
|
||||||
|
} } as any);
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /view message/i }));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /reply with connected provider/i }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('textbox', { name: /^provider$/i })).toHaveValue('Gmail · owner@gmail.test');
|
||||||
|
expect(screen.getByLabelText(/recipient/i)).toHaveValue('Maria Recruiter <maria@acme.test>');
|
||||||
|
expect(screen.getByLabelText(/subject/i)).toHaveValue('Re: Interview invite');
|
||||||
|
expect(screen.getByLabelText(/thread/i)).toHaveValue('thread-1');
|
||||||
|
fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Thank you. Tuesday works well.' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /review and send/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: /confirm email send/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/owner@gmail\.test.*maria@acme\.test/i)).toBeInTheDocument();
|
||||||
|
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||||
|
expect(screen.getByLabelText(/message/i)).toHaveValue('Thank you. Tuesday works well.');
|
||||||
|
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /review and send/i }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^send email$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/email/send', {
|
||||||
|
jobApplicationId: 42,
|
||||||
|
provider: 'gmail',
|
||||||
|
clientRequestId: '00000000-0000-4000-8000-000000000123',
|
||||||
|
to: 'Maria Recruiter <maria@acme.test>',
|
||||||
|
subject: 'Re: Interview invite',
|
||||||
|
bodyText: 'Thank you. Tuesday works well.',
|
||||||
|
threadId: 'thread-1',
|
||||||
|
confirmed: true,
|
||||||
|
}));
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||||
|
expect(await screen.findByText(/sent successfully/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /review and send/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not offer retry when delivery is uncertain', 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.mockRejectedValue({ response: { status: 409, data: {
|
||||||
|
attemptId: 'attempt-1', status: 'uncertain', duplicate: false, failureCategory: 'transport_interrupted',
|
||||||
|
} } });
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /view message/i }));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /reply with connected provider/i }));
|
||||||
|
fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Synthetic reply.' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /review and send/i }));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /^send email$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||||
|
expect(await screen.findByText(/delivery status is uncertain/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: /prepare new attempt/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /review and send/i })).toBeDisabled();
|
||||||
|
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('uses one hub for linked messages and recruitment suggestions', async () => {
|
test('uses one hub for linked messages and recruitment suggestions', async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
@@ -147,14 +225,16 @@ describe('CorrespondenceInboxPage', () => {
|
|||||||
test('legacy Gmail review route redirects into the consolidated hub filter', async () => {
|
test('legacy Gmail review route redirects into the consolidated hub filter', async () => {
|
||||||
render(
|
render(
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<I18nProvider>
|
<ConfirmProvider>
|
||||||
<MemoryRouter initialEntries={['/correspondence/review']} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<I18nProvider>
|
||||||
<Routes>
|
<MemoryRouter initialEntries={['/correspondence/review']} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
<Route path="/correspondence/review" element={<Navigate to="/correspondence?view=review" replace />} />
|
<Routes>
|
||||||
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
|
<Route path="/correspondence/review" element={<Navigate to="/correspondence?view=review" replace />} />
|
||||||
</Routes>
|
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
|
||||||
</MemoryRouter>
|
</Routes>
|
||||||
</I18nProvider>
|
</MemoryRouter>
|
||||||
|
</I18nProvider>
|
||||||
|
</ConfirmProvider>
|
||||||
</ToastProvider>,
|
</ToastProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
||||||
import { api, getApiErrorMessage } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
|
import { useConfirm } from "../confirm";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import GmailReviewPage from "./GmailReviewPage";
|
import GmailReviewPage from "./GmailReviewPage";
|
||||||
|
|
||||||
@@ -62,11 +63,35 @@ type EmailMessageDetail = {
|
|||||||
attachments: Array<{ fileName?: string | null; mimeType?: string | null; sizeBytes?: number | null }>;
|
attachments: Array<{ fileName?: string | null; mimeType?: string | null; sizeBytes?: number | null }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EmailDraft = {
|
||||||
|
jobApplicationId: number;
|
||||||
|
companyName: string;
|
||||||
|
jobTitle: string;
|
||||||
|
provider: string;
|
||||||
|
providerName: string;
|
||||||
|
fromAddress: string;
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
bodyText: string;
|
||||||
|
threadId: string;
|
||||||
|
clientRequestId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmailSendResult = {
|
||||||
|
attemptId: string;
|
||||||
|
status: "pending" | "sending" | "sent" | "failed" | "uncertain";
|
||||||
|
duplicate: boolean;
|
||||||
|
externalMessageId?: string | null;
|
||||||
|
externalThreadId?: string | null;
|
||||||
|
failureCategory?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export default function CorrespondenceInboxPage() {
|
export default function CorrespondenceInboxPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const view = searchParams.get("view") === "review" ? "review" : "inbox";
|
const view = searchParams.get("view") === "review" ? "review" : "inbox";
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { confirm } = useConfirm();
|
||||||
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
|
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
|
||||||
const [providers, setProviders] = useState<EmailProviderStatus[]>([]);
|
const [providers, setProviders] = useState<EmailProviderStatus[]>([]);
|
||||||
const [selectedMessageId, setSelectedMessageId] = useState<number | null>(null);
|
const [selectedMessageId, setSelectedMessageId] = useState<number | null>(null);
|
||||||
@@ -79,6 +104,10 @@ export default function CorrespondenceInboxPage() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
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 [sendResult, setSendResult] = useState<EmailSendResult | null>(null);
|
||||||
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -154,6 +183,104 @@ export default function CorrespondenceInboxPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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({
|
||||||
|
title: "Replace email draft?",
|
||||||
|
message: "Starting this reply will discard the draft you are currently editing.",
|
||||||
|
confirmLabel: "Replace draft",
|
||||||
|
destructive: true,
|
||||||
|
}))) return;
|
||||||
|
const subject = messageDetail.subject.trim();
|
||||||
|
setDraft({
|
||||||
|
jobApplicationId: item.jobApplicationId,
|
||||||
|
companyName: item.companyName || "Unknown company",
|
||||||
|
jobTitle: item.jobTitle || "Unknown role",
|
||||||
|
provider: item.provider,
|
||||||
|
providerName: provider.displayName,
|
||||||
|
fromAddress: provider.address || "Connected account",
|
||||||
|
to: item.direction === "outbound" ? messageDetail.to : messageDetail.from,
|
||||||
|
subject: /^re:/i.test(subject) ? subject : `Re: ${subject || "Your message"}`,
|
||||||
|
bodyText: "",
|
||||||
|
threadId: messageDetail.threadId || item.externalThreadId || "",
|
||||||
|
clientRequestId: globalThis.crypto.randomUUID(),
|
||||||
|
});
|
||||||
|
setSendResult(null);
|
||||||
|
setSendError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
setSendError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const discardDraft = async () => {
|
||||||
|
if (draft?.bodyText.trim() && !(await confirm({
|
||||||
|
title: "Discard email draft?",
|
||||||
|
message: "Your unsent draft text will be lost.",
|
||||||
|
confirmLabel: "Discard draft",
|
||||||
|
destructive: true,
|
||||||
|
}))) return;
|
||||||
|
setDraft(null);
|
||||||
|
setSendResult(null);
|
||||||
|
setSendError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendDraft = async () => {
|
||||||
|
if (!draft || sending || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()) return;
|
||||||
|
const approved = await confirm({
|
||||||
|
title: "Confirm email send",
|
||||||
|
message: `Send with ${draft.providerName} (${draft.fromAddress}) to ${draft.to.trim()}? Subject: ${draft.subject.trim()}. Thread: ${draft.threadId || "New message"}.`,
|
||||||
|
confirmLabel: "Send email",
|
||||||
|
});
|
||||||
|
if (!approved) return;
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
setSendError(null);
|
||||||
|
try {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
setSendResult(response.data);
|
||||||
|
if (response.data.status === "sent") {
|
||||||
|
toast("Email sent and saved to this job.", "success");
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
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, "The email could not be sent."));
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepareNewAttempt = () => {
|
||||||
|
setDraft((current) => current ? { ...current, clientRequestId: globalThis.crypto.randomUUID() } : null);
|
||||||
|
setSendResult(null);
|
||||||
|
setSendError(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
sx={{
|
sx={{
|
||||||
@@ -215,6 +342,40 @@ export default function CorrespondenceInboxPage() {
|
|||||||
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? "Loading..." : "Refresh"}</Button>
|
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? "Loading..." : "Refresh"}</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{draft ? (
|
||||||
|
<Paper component="section" aria-labelledby="email-draft-title" variant="outlined" sx={{ p: 2, borderRadius: 3, 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 }}>Email draft</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{draft.companyName} · {draft.jobTitle}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button onClick={() => void discardDraft()} disabled={sending}>Discard draft</Button>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.25 }}>
|
||||||
|
<TextField label="Provider" value={`${draft.providerName} · ${draft.fromAddress}`} slotProps={{ input: { readOnly: true } }} />
|
||||||
|
<TextField label="Thread" value={draft.threadId || "New message"} slotProps={{ input: { readOnly: true } }} />
|
||||||
|
<TextField label="Recipient" type="email" value={draft.to} onChange={(event) => updateDraft({ to: event.target.value })} disabled={sending} inputProps={{ maxLength: 320 }} />
|
||||||
|
<TextField label="Subject" value={draft.subject} onChange={(event) => updateDraft({ subject: event.target.value })} disabled={sending} inputProps={{ maxLength: 998 }} />
|
||||||
|
<TextField label="Message" value={draft.bodyText} onChange={(event) => updateDraft({ bodyText: event.target.value })} disabled={sending} multiline minRows={6} inputProps={{ maxLength: 200000 }} helperText={`${draft.bodyText.length} / 200000 characters`} sx={{ gridColumn: "1 / -1" }} />
|
||||||
|
</Box>
|
||||||
|
{sendResult?.status === "sent" ? <Alert severity="success" sx={{ mt: 1.5 }}>Sent successfully. This exact attempt cannot be sent again.</Alert> : null}
|
||||||
|
{sendResult?.status === "uncertain" || sendResult?.status === "sending" || sendResult?.status === "pending" ? (
|
||||||
|
<Alert severity="warning" sx={{ mt: 1.5 }}>Delivery status is uncertain. Do not retry this draft. Check the provider Sent folder before taking any further action.</Alert>
|
||||||
|
) : null}
|
||||||
|
{sendResult?.status === "failed" ? (
|
||||||
|
<Alert severity="error" sx={{ mt: 1.5 }} action={<Button color="inherit" size="small" onClick={prepareNewAttempt}>Prepare new attempt</Button>}>
|
||||||
|
The provider confirmed that this attempt did not complete. Review the connection and draft before creating a new attempt.
|
||||||
|
</Alert>
|
||||||
|
) : 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 }}>
|
||||||
|
<Button variant="contained" onClick={() => void sendDraft()} disabled={sending || !!sendResult || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()}>
|
||||||
|
{sending ? "Sending…" : "Review and send"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
|
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
|
||||||
|
|
||||||
{!loading && items.length === 0 ? (
|
{!loading && items.length === 0 ? (
|
||||||
@@ -255,6 +416,11 @@ export default function CorrespondenceInboxPage() {
|
|||||||
{messageDetail.labels.map((label, index) => <Chip key={`${label}-${index}`} size="small" label={label} variant="outlined" />)}
|
{messageDetail.labels.map((label, index) => <Chip key={`${label}-${index}`} size="small" label={label} variant="outlined" />)}
|
||||||
{messageDetail.attachments.map((attachment, index) => <Chip key={`${attachment.fileName || "attachment"}-${index}`} size="small" label={attachment.fileName || "Attachment"} variant="outlined" />)}
|
{messageDetail.attachments.map((attachment, index) => <Chip key={`${attachment.fileName || "attachment"}-${index}`} size="small" label={attachment.fileName || "Attachment"} variant="outlined" />)}
|
||||||
</Box> : null}
|
</Box> : null}
|
||||||
|
{item.provider && providers.find((provider) => provider.provider === item.provider)?.canSend ? (
|
||||||
|
<Button sx={{ mt: 1.5 }} variant="outlined" onClick={() => void startReply(item)}>Reply with connected provider</Button>
|
||||||
|
) : item.provider && item.provider !== "manual" ? (
|
||||||
|
<Alert severity="info" sx={{ mt: 1.5 }}>Reconnect this provider with send access to reply from JobTracker.</Alert>
|
||||||
|
) : null}
|
||||||
</> : null}
|
</> : null}
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user