import { isUuidLike } from "@paperclipai/shared"; import { ApiError } from "@/api/client"; import { AgentMailCredentialField } from "@/features/connections/AgentMailCredentialField"; import { AgentMailApiKeyField } from "@/features/connections/AgentMailApiKeyField"; import { useEmailAddressCheck } from "@/features/connections/useEmailAddressCheck"; import { ChatSetupNavigation } from "@/components/chat/ChatSetupNavigation"; import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, ArrowRight, Check, Copy, ExternalLink, Mail, } from "lucide-react"; import { useCompany } from "@/context/CompanyContext"; import { useNavigate, useSearchParams, Link } from "@/lib/router"; import { agentsApi } from "@/api/agents"; import { issuesApi } from "@/api/issues"; import { projectsApi } from "@/api/projects"; import { toolsApi } from "@/api/tools"; import { emailApi } from "@/api/email"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardHeader, CardDescription } from "@/components/ui/card"; import { CopyText } from "@/components/CopyText"; import { StatusBadge } from "@/components/StatusBadge"; import { formatDateTime } from "@/lib/utils"; import { AgentSelect } from "@/components/AgentMultiSelect"; import { TrustPresetSection } from "@/components/TrustPresetSection"; import { EmailSafetyNotice } from "@/components/EmailSafetyNotice"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from "@/components/ui/dialog"; import { getTrustPreset, getLowTrustBoundary, lowTrustBoundaryHasScope, } from "@/lib/trust-policy-ui"; import { queryKeys } from "@/lib/queryKeys"; import type { AgentPermissions, EmailEndpointSummary, } from "@paperclipai/shared"; const selectClass = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm"; interface EmailSetupDraft { connectionId: string; step: 0 | 1; agentId: string; requestId: string; addressMode: "new" | "existing"; inboxId: string; username: string; domain: string; mode: "websocket" | "webhook"; domainSelected: boolean; takenAddresses: string[]; allowInboxKey: boolean; selectedCredentialId: string | null; } function readEmailSetupDraft(key: string): Partial { try { const value = JSON.parse(sessionStorage.getItem(key) ?? "{}"); if (!value || typeof value !== "object") return {}; const draft: Partial = {}; for (const field of ["connectionId", "agentId", "inboxId", "username", "domain"] as const) { if (typeof value[field] === "string") draft[field] = value[field]; } if (value.step === 0 || value.step === 1) draft.step = value.step; if (typeof value.requestId === "string" && isUuidLike(value.requestId)) draft.requestId = value.requestId; if (value.addressMode === "new" || value.addressMode === "existing") draft.addressMode = value.addressMode; if (value.mode === "websocket" || value.mode === "webhook") draft.mode = value.mode; if (value.selectedCredentialId === null || typeof value.selectedCredentialId === "string") draft.selectedCredentialId = value.selectedCredentialId; if (typeof value.allowInboxKey === "boolean") draft.allowInboxKey = value.allowInboxKey; if (typeof value.domainSelected === "boolean") draft.domainSelected = value.domainSelected; if (Array.isArray(value.takenAddresses)) draft.takenAddresses = value.takenAddresses .filter((address: unknown): address is string => typeof address === "string" && address.length <= 320).slice(-20); return draft; } catch { return {}; } } export function EmailEndpointSetup() { const { selectedCompanyId } = useCompany(); const [params] = useSearchParams(); if (!selectedCompanyId) return

Loading email setup…

; return ; } function EmailEndpointSetupForm({ companyId }: { companyId: string }) { const [params] = useSearchParams(); const navigate = useNavigate(); const cache = useQueryClient(); const resumeId = params.get("resume"); const setupId = params.get("setupId"); const draftKey = `paperclip.agentmail-setup:${companyId}:${resumeId ?? setupId ?? params.get("connectionId") ?? "new"}:${params.get("agentId") ?? "choose"}`; const [draft] = useState(() => { const saved = readEmailSetupDraft(draftKey); // A Finish setup link always names its original inbox, including when an // older client accidentally stored a replacement under that resume key. return resumeId && saved.requestId && saved.requestId !== resumeId ? {} : saved; }); const [connectionId, setConnectionId] = useState(draft.connectionId ?? params.get("connectionId") ?? ""); const [step, setStep] = useState<0 | 1 | 2>(draft.step ?? (resumeId ? 1 : 0)); const [agentId, setAgentId] = useState(draft.agentId ?? params.get("agentId") ?? ""); const [apiKey, setApiKey] = useState(""); const [selectedCredentialId, setSelectedCredentialId] = useState(draft.selectedCredentialId !== undefined ? draft.selectedCredentialId : connectionId || null); const [restrictedInbox, setRestrictedInbox] = useState(""); const [allowInboxKey, setAllowInboxKey] = useState(draft.allowInboxKey ?? false); const [requestId] = useState(() => draft.requestId ?? resumeId ?? (setupId && isUuidLike(setupId) ? setupId : crypto.randomUUID())); const [addressMode, setAddressMode] = useState<"new" | "existing">(draft.addressMode ?? "new"); const [inboxId, setInboxId] = useState(draft.inboxId ?? ""); const [username, setUsername] = useState(draft.username ?? ""); const [domain, setDomain] = useState(draft.domain ?? "agentmail.to"); const [domainSelected, setDomainSelected] = useState(draft.domainSelected ?? (!!draft.domain && draft.domain !== "agentmail.to")); const [takenAddresses, setTakenAddresses] = useState(draft.takenAddresses ?? []); const [mode, setMode] = useState<"websocket" | "webhook">(draft.mode ?? "websocket"); const [trustOpen, setTrustOpen] = useState(false); const [permissions, setPermissions] = useState>({}); const suggestedUsername = useRef(false); useEffect(() => { if (!companyId) return; // Save progress, never the API key. The same request ID resumes partial setup. try { if (step === 2) sessionStorage.removeItem(draftKey); else sessionStorage.setItem(draftKey, JSON.stringify({ connectionId, step, agentId, requestId, addressMode, inboxId, username, domain, domainSelected, takenAddresses, mode, allowInboxKey, selectedCredentialId })); } catch { /* Setup remains usable when browser storage is unavailable. */ } }, [companyId, draftKey, connectionId, step, agentId, requestId, addressMode, inboxId, username, domain, domainSelected, takenAddresses, mode, allowInboxKey, selectedCredentialId]); const agents = useQuery({ queryKey: queryKeys.agents.list(companyId), queryFn: () => agentsApi.list(companyId), enabled: !!companyId }); const projects = useQuery({ queryKey: queryKeys.projects.list(companyId), queryFn: () => projectsApi.list(companyId), enabled: !!companyId && trustOpen }); const boundaryIssues = useQuery({ queryKey: ["email-boundary-issues", companyId], queryFn: () => issuesApi.list(companyId), enabled: !!companyId && trustOpen }); const chosen = agents.data?.find(a => a.id === agentId); const lowTrust = getTrustPreset(chosen?.permissions) === "low_trust_review"; const scoped = lowTrustBoundaryHasScope(getLowTrustBoundary(chosen?.permissions)); const inspected = useQuery({ queryKey: ["email-credential-inspect", companyId, connectionId], queryFn: () => emailApi.inspectSaved(companyId, connectionId), enabled: !!companyId && !!connectionId, retry: false }); const inboxes = useQuery({ queryKey: ["email-inboxes", companyId], queryFn: () => emailApi.list(companyId), enabled: !!companyId }); // A provider failure can leave an inbox allocated under this request. Resume // that exact endpoint; its agent and address are already fixed server-side. const pendingEndpoint = inboxes.data?.find(i => i.id === requestId && i.status !== "archived"); const pendingAddress = pendingEndpoint?.address; const resumeAccount = useQuery({ queryKey: ["email-resume-account", companyId, pendingEndpoint?.connectionId], queryFn: () => toolsApi.getConnection(pendingEndpoint!.connectionId), enabled: !!resumeId && requestId === resumeId && !!pendingEndpoint && !connectionId, retry: false, }); useEffect(() => { if (!resumeId || !pendingEndpoint || connectionId || !resumeAccount.isSuccess) return; const savedAccount = resumeAccount.data?.config?.credentialConnectionId; if (typeof savedAccount === "string") setConnectionId(savedAccount); else setStep(0); setMode(pendingEndpoint.receiveMode); }, [resumeId, pendingEndpoint, connectionId, resumeAccount.isSuccess, resumeAccount.data]); useEffect(() => { if (pendingEndpoint) setAgentId(pendingEndpoint.assignedAgentId); }, [pendingEndpoint]); const scopedKey = inspected.data?.scope.scope_type === "inbox"; const customDomains = [...new Set(inspected.data?.domains .filter(d => d.status === "VERIFIED" && d.domain.toLowerCase() !== "agentmail.to") .map(d => d.domain.toLowerCase()) ?? [])]; const defaultDomain = customDomains[0] ?? "agentmail.to"; useEffect(() => { if (inspected.isSuccess && !domainSelected && !pendingAddress) setDomain(defaultDomain); }, [inspected.isSuccess, domainSelected, pendingAddress, defaultDomain]); useEffect(() => { if (!scopedKey || !inboxes.isSuccess) return; if (pendingAddress || allowInboxKey) { setAddressMode("existing"); setInboxId(inspected.data?.inboxes[0]?.inbox_id ?? ""); } else if (step === 1) { // Old drafts must recover at the key choice, not return to a locked form. setRestrictedInbox(inspected.data?.inboxes[0]?.inbox_id ?? "this inbox"); setSelectedCredentialId(null); setStep(0); } }, [scopedKey, inspected.data, inboxes.isSuccess, pendingAddress, allowInboxKey, step]); useEffect(() => { if (chosen && !suggestedUsername.current) { suggestedUsername.current = true; if (!username) setUsername(chosen.name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").slice(0, 64)); } }, [chosen, username]); const connect = useMutation({ mutationFn: async () => { if (pendingAddress) return true; const details = selectedCredentialId ? await emailApi.inspectSaved(companyId, selectedCredentialId) : await emailApi.inspect(companyId, apiKey.trim()); if (details.scope.scope_type === "inbox" && !allowInboxKey) { setRestrictedInbox(details.inboxes[0]?.inbox_id ?? "this inbox"); return false; } const changingAccount = !!connectionId && selectedCredentialId !== connectionId; const nextRequestId = changingAccount ? crypto.randomUUID() : requestId; let id = selectedCredentialId; if (!id) { const result = await emailApi.connect(companyId, { apiKey: apiKey.trim(), grantKind: "organization", allAgents: false, agentIds: [agentId], idempotencyKey: nextRequestId, }); id = result.id; } cache.setQueryData(["email-credential-inspect", companyId, id], details); setSelectedCredentialId(id); setApiKey(""); void cache.invalidateQueries({ queryKey: ["email-credentials", companyId] }); void cache.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) }); if (changingAccount) { // Save the replacement credential before retiring the original draft. // A failed save/cleanup leaves the original URL recoverable; a completed // switch gets its own URL so refreshing cannot revive an archived draft. if (pendingEndpoint) { await emailApi.control(pendingEndpoint.id, "remove"); await cache.invalidateQueries({ queryKey: ["email-inboxes", companyId] }); } openDraft({ connectionId: id, selectedCredentialId: id, agentId, step: 1, requestId: nextRequestId, addressMode: details.scope.scope_type === "inbox" ? "existing" : "new", inboxId: details.scope.scope_type === "inbox" ? details.inboxes[0]?.inbox_id ?? "" : "", username, domain: "agentmail.to", domainSelected: false, takenAddresses: [], mode, allowInboxKey, }); return false; } setConnectionId(id); setRestrictedInbox(""); setAddressMode(details.scope.scope_type === "inbox" ? "existing" : "new"); if (details.scope.scope_type === "inbox") setInboxId(details.inboxes[0]?.inbox_id ?? ""); return true; }, onSuccess: ready => { if (ready) setStep(1); }, }); function selectCredential(id: string) { setSelectedCredentialId(id); setApiKey(""); setRestrictedInbox(""); setAllowInboxKey(false); setDomainSelected(false); setTakenAddresses([]); connect.reset(); } const agentDetail = useQuery({ queryKey: queryKeys.agents.detail(agentId), queryFn: () => agentsApi.get(agentId), enabled: !!agentId && trustOpen }); const trust = useMutation({ mutationFn: () => agentsApi.updatePermissions(agentId, { ...permissions, canCreateAgents: permissions.canCreateAgents ?? false, canCreateSkills: permissions.canCreateSkills ?? true, canAssignTasks: agentDetail.data?.access?.canAssignTasks ?? false, }, companyId), onSuccess: () => { setTrustOpen(false); void cache.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }); }, }); const setup = useMutation({ mutationFn: () => emailApi.setup(companyId, { assignedAgentId: agentId, credentialConnectionId: connectionId, ...(pendingAddress ? { inboxId: pendingAddress } : addressMode === "existing" ? { inboxId } : { username, domain }), receiveMode: mode, idempotencyKey: requestId, }), onError: async (error) => { if (error instanceof ApiError && (error.body as { code?: string } | null)?.code === "agentmail_address_taken") { setTakenAddresses(previous => [...new Set([...previous, `${username}@${domain}`.toLowerCase()])].slice(-20)); } await cache.invalidateQueries({ queryKey: ["email-inboxes", companyId] }); }, onSuccess: () => { void cache.invalidateQueries({ queryKey: ["email-inboxes", companyId] }); void cache.invalidateQueries({ queryKey: queryKeys.chatEndpoints.list(companyId) }); void cache.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) }); void cache.invalidateQueries({ queryKey: queryKeys.tools.connectionInstalls(connectionId) }); setStep(2); }, }); const address = pendingAddress ?? (addressMode === "existing" ? inboxId : `${username}@${domain}`); const knownAddresses = new Set([...takenAddresses, ...(inspected.data?.inboxes.map(i => i.inbox_id.toLowerCase()) ?? [])]); const checkingNewAddress = step === 1 && addressMode === "new" && !pendingAddress && !scopedKey; const knownAddress = checkingNewAddress && knownAddresses.has(address.toLowerCase()); const validUsername = /^[a-z0-9][a-z0-9._-]*$/.test(username) && username.length <= 64; const addressCheck = useEmailAddressCheck(companyId, connectionId, username, domain, checkingNewAddress && validUsername && !!inspected.data && !knownAddress); const addressTaken = knownAddress || addressCheck.result?.status === "taken"; const suggestions = checkingNewAddress && validUsername && (addressTaken || addressCheck.result?.status === "unknown") ? ["-agent", "-team", `-${requestId.slice(0, 6)}`] .map(suffix => `${username.slice(0, 64 - suffix.length)}${suffix}`) .filter(name => !knownAddresses.has(`${name}@${domain}`)) : []; const assignedInbox = addressMode === "existing" && inboxes.data?.some(i => i.id !== requestId && i.address === address && i.status !== "archived"); const addressError = addressTaken ? "This email address is already in use. Choose a different address." : assignedInbox ? "This inbox is already assigned to an agent." : null; const error = connect.error ?? (!addressTaken ? setup.error : null) ?? resumeAccount.error ?? inspected.error ?? agents.error; const busy = connect.isPending || setup.isPending; const identityReady = inboxes.isSuccess && (!resumeId || requestId !== resumeId || !!pendingEndpoint) && (!pendingEndpoint || pendingEndpoint.assignedAgentId === agentId); const canContinue = identityReady && !!chosen && !busy && !(lowTrust && !scoped) && (!!pendingAddress || !!selectedCredentialId || !!apiKey.trim()); const canCreate = identityReady && !!chosen && !busy && !!inspected.data && !(lowTrust && !scoped) && !addressError && !addressCheck.checking && (!!pendingAddress || (addressMode === "existing" ? !!inboxId : validUsername)); const openTrust = () => { if (chosen) { setPermissions(chosen.permissions); setTrustOpen(true); } }; const leave = () => navigate(`/apps/chat/${setup.data?.id ?? pendingEndpoint?.id}/settings`); const cancel = () => { try { sessionStorage.removeItem(draftKey); } catch {} navigate("/apps"); }; function openDraft(nextDraft: EmailSetupDraft & { requestId: string; connectionId: string }) { try { sessionStorage.setItem(`paperclip.agentmail-setup:${companyId}:${nextDraft.requestId}:${agentId}`, JSON.stringify(nextDraft)); } catch { /* The new link still restores the agent and saved account. */ } navigate(`/apps/chat/connect?${new URLSearchParams({ provider: "agentmail", purpose: "chat", setupId: nextDraft.requestId, agentId, connectionId: nextDraft.connectionId })}`); } const chooseAnotherAddress = () => { // Preserve the allocated inbox and its resumable setup. A different address // must use a new provider client_id, never silently rename a retry. const nextRequestId = crypto.randomUUID(); openDraft({ connectionId, selectedCredentialId: connectionId, agentId, step: 1, requestId: nextRequestId, addressMode: "new", username: "", inboxId: "", domain: pendingAddress?.slice(pendingAddress.lastIndexOf("@") + 1) ?? domain, domainSelected: true, takenAddresses, mode, allowInboxKey: false, }); }; return

{step === 2 ? "Your agent’s email is ready" : "Give an agent an email address"}

{step < 2 && inboxes.isError &&

Could not load email setup progress. {inboxes.error.message}

} {step < 2 && resumeId === requestId && inboxes.isSuccess && !pendingEndpoint &&

This email setup could not be found. Return to Connectors and start a new connection.

} {step < 2 && { setup.reset(); setStep(index as 0 | 1); }} />} {step === 0 &&
{ event.preventDefault(); if (canContinue) connect.mutate(); }}>
!["terminated", "pending_approval"].includes(a.status))} onChange={id => { setAgentId(id); setUsername((agents.data?.find(a => a.id === id)?.name ?? "").toLowerCase().replace(/[^a-z0-9._-]+/g, "-").slice(0, 64)); }} />
{!pendingAddress && { setApiKey(value); connect.reset(); }} disabled={busy} />} {restrictedInbox &&

That key only connects {restrictedInbox}. Choose a saved account key or enter one to create a new address.

} {lowTrust && !scoped &&

This agent needs a work boundary before it can receive email.

} {error &&

{error.message}

}
} {step === 1 &&
{ event.preventDefault(); if (canCreate) setup.mutate(); }}>
{pendingAddress ? <>

{pendingAddress}

This address was created in AgentMail. Finish connecting it to {chosen?.name}{scopedKey ? "." : ", or choose a different address. The original inbox will stay in AgentMail."}

{!scopedKey && } : addressMode === "new" ? <>
{ setUsername(event.target.value.toLowerCase()); setup.reset(); }} />
: } {scopedKey && !pendingAddress && } {addressError && } {checkingNewAddress && !addressError &&

{addressCheck.checking ? "Checking address…" : addressCheck.error ? `Could not check this address. ${addressCheck.error}` : addressCheck.result?.status === "unknown" ? "AgentMail confirms availability when you create the address." : null}

} {suggestions.length > 0 &&
Try: {suggestions.map(name => )}
} {!scopedKey && !pendingAddress && }

How it Works

Incoming email creates tasks for {chosen?.name}. Replies stay in the same task.
Advanced options
{addressMode === "new" && }

{lowTrust && scoped ? "Low-trust review configured" : "Manage which tasks and tools this agent can access."}

{error &&

{error.message}

} {inspected.isPending &&

Loading email options…

}
} {step === 2 &&

{setup.data?.address}

{chosen?.name} can now receive email at this address.

} Trust settings · {chosen?.name} Changes apply to all of this agent’s work. Use a dedicated email agent if its other tasks need broader access. ({ id: p.id, label: p.name, }))} issueCandidates={(boundaryIssues.data ?? []).map((issue) => ({ id: issue.id, label: `${issue.identifier} · ${issue.title}`, }))} allowSingleIssue={false} candidatesLoading={projects.isPending || boundaryIssues.isPending} />

Low trust limits Paperclip access; it does not sandbox the runtime. Review filesystem, tool, and secret access separately.

{(trust.error || projects.error || boundaryIssues.error) && (

{(trust.error ?? projects.error ?? boundaryIssues.error)?.message}

)}
; } export function EmailConnectionInboxes({ companyId, connectionId, canConfigure, }: { companyId: string; connectionId: string; canConfigure: boolean; }) { const query = useQuery({ queryKey: ["email-inboxes", companyId], queryFn: () => emailApi.list(companyId), refetchInterval: 10_000, }); const connections = useQuery({ queryKey: queryKeys.tools.connections(companyId), queryFn: () => toolsApi.listConnections(companyId), }); const children = new Set( connections.data?.connections .filter((c) => c.config?.credentialConnectionId === connectionId) .map((c) => c.id), ); const inboxes = query.data?.filter( (i) => i.connectionId === connectionId || children.has(i.connectionId), ) ?? []; return (

Give an agent an email address

Each email conversation becomes a task.

{canConfigure && ( )}
{inboxes.map((i) => (
{i.address} {i.lastError ?? (i.status === "active" ? "Receiving email" : i.status)}
))} {!!inboxes.length && } {query.error && (

{query.error.message}

)}
); } export function EmailEndpointSettings({ endpointId, companyId, assignedAgentName, }: { endpointId: string; companyId: string; assignedAgentName: string; }) { const cache = useQueryClient(); const query = useQuery({ queryKey: ["email-inboxes", companyId], queryFn: () => emailApi.list(companyId), refetchInterval: 10_000, }); const inbox = query.data?.find( (row: EmailEndpointSummary) => row.id === endpointId, ); const [removed, setRemoved] = useState(false); const [replacementKey, setReplacementKey] = useState(""); const [reconnectOpen, setReconnectOpen] = useState(false); useEffect(() => { if (inbox?.lastError) setReconnectOpen(true); }, [inbox?.lastError]); const [receiveMode, setReceiveMode] = useState<"websocket" | "webhook" | "">( "", ); const reconnect = useMutation({ mutationFn: () => emailApi.reconnect( endpointId, replacementKey, receiveMode || inbox!.receiveMode, ), onSuccess: () => { setReplacementKey(""); }, onSettled: () => { void cache.invalidateQueries({ queryKey: ["email-inboxes", companyId] }); }, }); const control = useMutation({ mutationFn: (action: "pause" | "resume" | "remove") => emailApi.control(endpointId, action), onSuccess: (result) => { setRemoved(result.status === "archived"); void cache.invalidateQueries({ queryKey: ["email-inboxes", companyId] }); }, }); if (removed) return

Inbox disconnected. Email history remains in its tasks.

; if (!inbox) return (

{query.error?.message ?? "Loading email inbox…"}

); return (

{assignedAgentName}’s email address

{inbox.address ? ( {inbox.address} ) : "Email inbox"}

{inbox.address && ( View inbox )}
{inbox.status === "active" && inbox.address && (

Send an email to this address to start a task with {assignedAgentName}.

)}

How it Works

Incoming email creates tasks for {assignedAgentName}. Replies stay in the same task. Task comments stay internal; use Email reply to send an email.

Receiving email

{(inbox.status !== "active" || inbox.lastError) && ( )}
{["active", "paused"].includes(inbox.status) && ( )}
{inbox.status === "paused" && (

Receiving is paused. Resume to receive new email.

)}
Receiving mode
{inbox.receiveMode === "websocket" ? "Live connection" : "Webhook"}
Last mail check
{inbox.lastSyncAt ? formatDateTime(inbox.lastSyncAt) : "Not checked yet"}
{inbox.lastError &&

{inbox.lastError}

} {control.error && control.variables !== "remove" && (

{control.error.message}

)}
setReconnectOpen(event.currentTarget.open)} className="border-t border-border pt-5"> Reconnect inbox

Replace the API key or change how this inbox receives email. The email address and task history stay the same.

{reconnect.error &&

{reconnect.error.message}

} {reconnect.isSuccess &&

Inbox reconnected.

}

Disconnect inbox

Stop receiving email in Paperclip. The inbox stays in AgentMail.

{control.error && control.variables === "remove" && (

{control.error.message}

)}
); }