fix(agentmail): preserve explicit account selection during recovery

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-10-01 11:37:02 -05:00
co-authored by Paperclip
parent 706c1d707e
commit 40b659cc47
2 changed files with 37 additions and 13 deletions
@@ -35,9 +35,10 @@ export function AgentMailIntentSetup({ companyId, agentId, requestId, savedCrede
const [setupRequestId, setSetupRequestId] = useState(draft?.setupRequestId ?? requestId);
const [apiKey, setApiKey] = useState("");
// A refresh can interrupt the response after the server has saved the key.
// Recover that account unless the user deliberately started a different setup.
// Recover that account unless the user selected another key or started a
// different setup after the interrupted request.
const [credentialId, setCredentialId] = useState(draft?.credentialId
?? (!draft || draft.setupRequestId === requestId ? savedCredentialId ?? null : null));
?? (!draft || (draft.setupRequestId === requestId && !draft.selectedCredentialId) ? savedCredentialId ?? null : null));
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(draft?.selectedCredentialId ?? null);
const [inboxConnectionId, setInboxConnectionId] = useState(readyConnectionId ?? draft?.inboxConnectionId ?? null);
useEffect(() => {
@@ -45,8 +46,8 @@ export function AgentMailIntentSetup({ companyId, agentId, requestId, savedCrede
catch { /* Keep setup usable without browser storage. */ }
}, [draftKey, setupRequestId, credentialId, inboxConnectionId, selectedCredentialId]);
const changeKey = useMutation({
mutationFn: async () => {
const pending = (await emailApi.list(companyId)).find(inbox => inbox.id === setupRequestId && inbox.status !== "archived");
mutationFn: async (pendingRequestId: string) => {
const pending = (await emailApi.list(companyId)).find(inbox => inbox.id === pendingRequestId && inbox.status !== "archived");
if (pending?.address) throw new Error(`The address ${pending.address} is already reserved. Finish setup with its saved key.`);
if (pending) await emailApi.control(pending.id, "remove");
},
@@ -59,14 +60,14 @@ export function AgentMailIntentSetup({ companyId, agentId, requestId, savedCrede
},
});
const setup = useMutation({
mutationFn: async () => {
let connectionId = inboxConnectionId;
mutationFn: async (submitted: { accountId: string | null; apiKey: string; requestId: string; inboxId: string | null }) => {
let connectionId = submitted.inboxId;
if (!connectionId) {
let accountId = credentialId || selectedCredentialId;
let accountId = submitted.accountId;
if (!accountId) {
const account = await emailApi.connect(companyId, {
apiKey: apiKey.trim(), grantKind: "organization", allAgents: false,
agentIds: [agentId], idempotencyKey: setupRequestId,
apiKey: submitted.apiKey, grantKind: "organization", allAgents: false,
agentIds: [agentId], idempotencyKey: submitted.requestId,
});
accountId = account.id;
setCredentialId(accountId);
@@ -75,7 +76,7 @@ export function AgentMailIntentSetup({ companyId, agentId, requestId, savedCrede
setCredentialId(accountId);
const inbox = await emailApi.setup(companyId, {
assignedAgentId: agentId, credentialConnectionId: accountId,
receiveMode: "websocket", idempotencyKey: setupRequestId,
receiveMode: "websocket", idempotencyKey: submitted.requestId,
});
connectionId = inbox.connectionId;
setInboxConnectionId(connectionId);
@@ -88,7 +89,10 @@ export function AgentMailIntentSetup({ companyId, agentId, requestId, savedCrede
});
return <form className="mt-4 space-y-4" data-testid="agentmail-inline-setup" onSubmit={event => {
event.preventDefault();
if (!setup.isPending && !changeKey.isPending && !declining) setup.mutate();
const accountId = credentialId || selectedCredentialId;
if (!setup.isPending && !changeKey.isPending && !declining && (accountId || inboxConnectionId || apiKey.trim())) {
setup.mutate({ accountId, apiKey: apiKey.trim(), requestId: setupRequestId, inboxId: inboxConnectionId });
}
}}>
{credentialId || inboxConnectionId
? <p className="text-sm text-muted-foreground">{inboxConnectionId ? "Your inbox is ready. Continue to resume the chat." : "API key saved. Finish creating the inbox."}</p>
@@ -96,7 +100,7 @@ export function AgentMailIntentSetup({ companyId, agentId, requestId, savedCrede
onConnectionChange={id => { setSelectedCredentialId(id); setApiKey(""); }}
value={apiKey} onChange={setApiKey} disabled={setup.isPending || changeKey.isPending || declining} />}
{credentialId && !inboxConnectionId && <Button type="button" variant="link" size="sm" className="h-auto p-0"
disabled={setup.isPending || changeKey.isPending || declining} onClick={() => changeKey.mutate()}>
disabled={setup.isPending || changeKey.isPending || declining} onClick={() => changeKey.mutate(setupRequestId)}>
{changeKey.isPending ? "Checking setup…" : "Change API key"}
</Button>}
{changeKey.error && <p className="text-sm text-destructive" role="alert">{changeKey.error.message}</p>}
@@ -126,12 +126,12 @@ async function flush() {
async function waitForAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
await flush();
try {
assertion();
return;
} catch (error) {
lastError = error;
await flush();
}
}
throw lastError;
@@ -215,6 +215,8 @@ beforeEach(() => {
afterEach(async () => {
if (root) await act(() => root?.unmount());
await queryClient.cancelQueries();
queryClient.clear();
host?.remove();
document.body
.querySelectorAll("[data-radix-focus-guard]")
@@ -607,6 +609,7 @@ describe("AgentMail inline setup", () => {
payload: { ...pendingConnectionIntentInteraction.payload, purpose: "channel", serviceSlug: "agentmail", serviceName: "AgentMail" },
};
async function enterKey() {
await waitForAssertion(() => expect(document.querySelector('input[type="password"]')).not.toBeNull());
const input = document.querySelector('input[type="password"]') as HTMLInputElement;
await act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "fixture-api-key");
@@ -662,6 +665,8 @@ describe("AgentMail inline setup", () => {
emailListMock.mockResolvedValue([{ id: interaction.id, address: null, status: "draft" }]);
renderBody(interaction); await flush();
await waitForAssertion(() => expect(button("Connect AgentMail")?.disabled).toBe(false));
expect((document.querySelector("select") as HTMLSelectElement)?.value).toBe("saved-account");
expect(emailConnectMock).not.toHaveBeenCalled();
await act(() => button("Connect AgentMail")!.click()); await flush();
expect(emailConnectMock).not.toHaveBeenCalled();
await act(() => button("Change API key")!.click()); await flush();
@@ -702,6 +707,21 @@ describe("AgentMail inline setup", () => {
credentialConnectionId: "server-saved-account", idempotencyKey: resumedInteraction.id,
}));
});
it("keeps a different saved-key selection when recovering an interrupted save", async () => {
const resumedInteraction = { ...interaction, id: "a381e91e-7127-427d-9d2d-519d4deba89f" };
sessionStorage.setItem(`paperclip.agentmail-inline:${interaction.companyId}:${resumedInteraction.id}`, JSON.stringify({
setupRequestId: resumedInteraction.id, credentialId: null, inboxConnectionId: null, selectedCredentialId: "selected-account",
}));
emailCredentialsMock.mockResolvedValue([{ id: "selected-account", label: "My selected key", scope: "organization", createdAt: "2026-10-01T14:00:00Z" }]);
setupOptionsMock.mockResolvedValue({ existingConnections: [], emailSetup: { credentialConnectionId: "earlier-account", readyConnectionId: null } });
renderBody(resumedInteraction); await flush();
await waitForAssertion(() => expect((document.querySelector('select') as HTMLSelectElement)?.value).toBe("selected-account"));
await act(() => button("Connect AgentMail")!.click()); await flush();
expect(emailConnectMock).not.toHaveBeenCalled();
expect(emailSetupMock).toHaveBeenCalledWith(interaction.companyId, expect.objectContaining({
credentialConnectionId: "selected-account", idempotencyKey: resumedInteraction.id,
}));
});
it("does not restore an abandoned server account after the user changes keys", async () => {
const replacementRequestId = "a381e91e-7127-427d-9d2d-519d4deba89f";
sessionStorage.setItem(`paperclip.agentmail-inline:${interaction.companyId}:${interaction.id}`, JSON.stringify({