import { useSendToAgentChat } from "@agent-native/core/client/agent-chat";
import { ExtensionSlot } from "@agent-native/core/client/extensions";
import { useT } from "@agent-native/core/client/i18n";
import {
IconPlus,
IconCheck,
IconSettings,
IconChevronLeft,
IconArrowUp,
} from "@tabler/icons-react";
import { useState, useEffect, useRef } from "react";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useApolloPerson } from "@/hooks/use-apollo";
import {
useIntegration,
useAllIntegrations,
useHubSpotContact,
useGongCalls,
usePylonContact,
isAuthError,
} from "@/hooks/use-integrations";
import type { MailIntegrationStatuses } from "@/lib/integration-status";
import { isMcpEmbedSurface } from "@/lib/mcp-embed";
import { cn } from "@/lib/utils";
function safeExternalHref(value?: string | null): string | null {
if (!value) return null;
try {
const url = new URL(value.trim());
return url.protocol === "http:" || url.protocol === "https:"
? url.toString()
: null;
} catch {
return null;
}
}
// ─── Integration definitions ────────────────────────────────────────────────
type ProviderId = "apollo" | "hubspot" | "gong" | "pylon";
interface IntegrationDef {
id: ProviderId;
name: string;
descriptionKey: string;
keyPlaceholder: string;
helpUrl: string;
helpSteps: string[];
logo: React.ReactNode;
}
const INTEGRATIONS: IntegrationDef[] = [
{
id: "apollo",
name: "Apollo",
descriptionKey: "mail.integrations.apolloDescription",
keyPlaceholder: "Apollo API key...",
helpUrl: "https://app.apollo.io/#/settings/integrations/api",
helpSteps: [
"Log in to Apollo.io",
"Go to Settings > Integrations > API",
'Click "Connect" to generate a key',
],
logo: (
),
},
{
id: "hubspot",
name: "HubSpot",
descriptionKey: "mail.integrations.hubspotDescription",
keyPlaceholder: "HubSpot private app token...",
helpUrl: "https://developers.hubspot.com/docs/api/private-apps",
helpSteps: [
"Go to HubSpot > Settings > Integrations > Private Apps",
"Create a private app with CRM scopes",
"Copy the access token",
],
logo: (
),
},
{
id: "gong",
name: "Gong",
descriptionKey: "mail.integrations.gongDescription",
keyPlaceholder: "Gong API key or access_key:secret...",
helpUrl: "https://app.gong.io/company/api",
helpSteps: [
"Go to Gong > Company Settings > API",
"Generate API credentials",
"Copy the access key (or key:secret)",
],
logo: (
),
},
{
id: "pylon",
name: "Pylon",
descriptionKey: "mail.integrations.pylonDescription",
keyPlaceholder: "Pylon API token...",
helpUrl: "https://docs.usepylon.com/pylon-docs/developer/api",
helpSteps: [
"Go to Pylon > Settings > API",
"Create an API token (requires Admin)",
"Copy the bearer token",
],
logo: (
),
},
];
// ─── Main Sidebar Component ─────────────────────────────────────────────────
export function IntegrationsSidebar({
email,
displayName,
recentEmails,
threadId,
focusedEmailId,
}: {
email: string;
displayName: string;
recentEmails: { id: string; subject: string }[];
threadId?: string;
focusedEmailId?: string;
}) {
const statuses = useAllIntegrations();
const anyConnected =
statuses.apollo || statuses.hubspot || statuses.gong || statuses.pylon;
return (
{/* Integration data sections */}
{statuses.apollo &&
}
{statuses.hubspot &&
}
{statuses.gong &&
}
{statuses.pylon &&
}
{/* Generic profile if nothing connected */}
{!anyConnected && (
{displayName}
{displayName !== email && (
{email}
)}
{email.split("@")[1]}
)}
{/* Recent emails */}
{recentEmails.length > 0 && (
<>
Recent
{recentEmails.map((e) => (
{e.subject.length > 40
? e.subject.slice(0, 40) + "..."
: e.subject}
))}
>
)}
{/* Tool extension-point slot — user-installed widgets render here */}
{/* Integration setup */}
);
}
// ─── Integration Setup ──────────────────────────────────────────────────────
function IntegrationSetup({ statuses }: { statuses: MailIntegrationStatuses }) {
const [expanded, setExpanded] = useState(false);
const [configuring, setConfiguring] = useState(null);
if (configuring) {
const def = INTEGRATIONS.find((i) => i.id === configuring)!;
return (
setConfiguring(null)} />
);
}
if (!expanded) {
const connectedCount = [
statuses.apollo,
statuses.hubspot,
statuses.gong,
statuses.pylon,
].filter(Boolean).length;
return (
setExpanded(true)}
className="text-[11px] text-muted-foreground/70 hover:text-muted-foreground transition-colors"
>
{connectedCount > 0
? `Integrations (${connectedCount}/${INTEGRATIONS.length})`
: "Add integrations"}
);
}
return (
Integrations
setExpanded(false)}
className="text-[10px] text-muted-foreground/70 hover:text-muted-foreground transition-colors"
>
Collapse
{INTEGRATIONS.map((def) => {
const connected = statuses[def.id];
return (
setConfiguring(def.id)}
/>
);
})}
);
}
function AddIntegrationButton() {
const t = useT();
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const textareaRef = useRef(null);
const { send, codeRequiredDialog } = useSendToAgentChat();
useEffect(() => {
if (open) {
setTimeout(() => textareaRef.current?.focus(), 50);
} else {
setValue("");
}
}, [open]);
const handleSubmit = () => {
const prompt = value.trim();
if (!prompt) return;
send({
message: `Add a new integration to the sidebar: ${prompt}`,
context: `The user wants to add a new integration to the contact sidebar. Their request: "${prompt}". Look at the existing integrations in client/components/email/IntegrationsSidebar.tsx and the pattern in server/routes/ and client/hooks/use-integrations.ts. Add the new provider following the same pattern: server route, hook, sidebar section, and logo. Use the real brand logo SVG if possible.`,
submit: true,
requiresCode: true,
});
setValue("");
setOpen(false);
};
return (
<>
{codeRequiredDialog}
{t("mail.integrations.addIntegration")}
{t("mail.integrations.newIntegration")}
{/Mac|iPhone|iPad/.test(navigator.userAgent) ? "⌘" : "Ctrl"}
+Enter {t("mail.compose.toSubmit")}
{t("mail.integrations.submitShortcut", {
shortcut: `${/Mac|iPhone|iPad/.test(navigator.userAgent) ? "⌘" : "Ctrl"}+Enter`,
})}
>
);
}
function IntegrationRow({
def,
connected,
onConfigure,
}: {
def: IntegrationDef;
connected: boolean;
onConfigure: () => void;
}) {
const t = useT();
const { disconnect } = useIntegration(def.id);
return (
{def.logo}
{def.name}
{t(def.descriptionKey)}
{connected ? (
{t("mail.toolbar.settings")}
onConfigure()}
className="text-[12px] text-foreground/70"
>
{t("mail.integrations.updateKey")}
disconnect.mutate()}
className="text-[12px] text-red-400/80"
>
{t("mail.integrations.disconnect")}
) : (
{t("mail.integrations.connect")}
)}
);
}
function IntegrationKeyEntry({
def,
onBack,
}: {
def: IntegrationDef;
onBack: () => void;
}) {
const t = useT();
const [apiKey, setApiKey] = useState("");
const { connect } = useIntegration(def.id);
const errorMessage =
connect.error instanceof Error ? connect.error.message : null;
return (
{
setApiKey(e.target.value);
if (connect.error) connect.reset();
}}
placeholder={def.keyPlaceholder}
autoFocus
className="flex-1 min-w-0 rounded-md border border-border bg-background px-2 py-1 text-[12px] outline-none focus:border-primary/50 placeholder:text-muted-foreground/40"
/>
{
if (apiKey.trim()) {
connect.mutate(apiKey.trim(), { onSuccess: onBack });
}
}}
disabled={!apiKey.trim() || connect.isPending}
className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{connect.isPending
? t("mail.integrations.checking")
: t("mail.integrations.save")}
{errorMessage && (
{errorMessage}
)}
{/* Instructions always visible */}
);
}
// ─── Integration Notice (error / no-data) ──────────────────────────────────
function IntegrationNotice({
email,
error,
providerId,
}: {
email: string;
error: unknown;
providerId: ProviderId;
}) {
const authErr = isAuthError(error);
const [reconnecting, setReconnecting] = useState(false);
const def = INTEGRATIONS.find((i) => i.id === providerId)!;
const { connect } = useIntegration(providerId);
const [apiKey, setApiKey] = useState("");
if (reconnecting) {
return (
setReconnecting(false)}
className="text-muted-foreground/50 hover:text-muted-foreground"
>
{def.logo}
Reconnect {def.name}
{
setApiKey(e.target.value);
if (connect.error) connect.reset();
}}
placeholder={def.keyPlaceholder}
autoFocus
className="flex-1 min-w-0 rounded-md border border-border bg-background px-2 py-1 text-[12px] outline-none focus:border-primary/50 placeholder:text-muted-foreground/40"
/>
{
if (apiKey.trim()) {
connect.mutate(apiKey.trim(), {
onSuccess: () => setReconnecting(false),
});
}
}}
disabled={!apiKey.trim() || connect.isPending}
className="shrink-0 rounded-md bg-primary px-2.5 py-1 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{connect.isPending ? "Checking..." : "Save"}
{connect.error instanceof Error && (
{connect.error.message}
)}
Open {def.name} Settings →
);
}
return (
{email}
{authErr ? (
setReconnecting(true)}
className="text-[11px] text-amber-400/80 hover:text-amber-300 mt-1 text-start"
>
{def.name} API key is invalid or expired —{" "}
reconnect
) : error ? (
Could not reach {def.name}
) : (
No data found in {def.name}
)}
);
}
// ─── Apollo Section ─────────────────────────────────────────────────────────
function ApolloSection({ email }: { email: string }) {
const t = useT();
const { data: person, isLoading, error } = useApolloPerson(email);
if (isLoading) return ;
if (error) {
return (
);
}
if (!person) {
// No enrichment data — show basic info (email + domain)
return (
{email}
{email.split("@")[1]}
);
}
const name =
person.first_name || person.last_name
? [person.first_name, person.last_name].filter(Boolean).join(" ")
: email;
const location = [person.city, person.state, person.country]
.filter(Boolean)
.join(", ");
const isEmbedded = isMcpEmbedSurface();
const shouldLoadRemotePhoto = person.photo_url && !isEmbedded;
const shouldLoadRemoteLogo = person.organization?.logo_url && !isEmbedded;
return (
<>
{/* Name & title */}
{shouldLoadRemotePhoto ? (
) : person.photo_url ? (
{name[0]?.toUpperCase()}
) : null}
{name}
{email}
{person.title && (
{person.title}
)}
{person.headline && person.headline !== person.title && (
{person.headline}
)}
{location && (
{location}
)}
{/* Company */}
{person.organization && (
<>
{shouldLoadRemoteLogo ? (
) : (
{person.organization.name?.[0]?.toUpperCase()}
)}
{person.organization.name}
{person.organization.short_description && (
{person.organization.short_description}
)}
{person.organization.industry && (
{person.organization.industry}
)}
{person.organization.estimated_num_employees && (
{person.organization.estimated_num_employees.toLocaleString()}
+ emp
)}
{person.organization.founded_year && (
Est. {person.organization.founded_year}
)}
>
)}
{/* Links */}
{(person.linkedin_url ||
person.twitter_url ||
person.github_url ||
person.organization?.website_url) && (
<>
>
)}
{/* Phone numbers */}
{person.phone_numbers && person.phone_numbers.length > 0 && (
<>
{person.phone_numbers.map((p, i) => (
{p.raw_number}
{p.type && (
({p.type})
)}
))}
>
)}
{/* Employment history */}
{person.employment_history && person.employment_history.length > 1 && (
<>
{t("mail.integrations.experience")}
{person.employment_history.slice(0, 4).map((job, i) => (
{job.title}
{job.organization_name}
))}
>
)}
>
);
}
// ─── HubSpot Section ────────────────────────────────────────────────────────
function HubSpotSection({ email }: { email: string }) {
const t = useT();
const {
data: contact,
isLoading,
error,
} = useHubSpotContact(email) as {
data: Record | undefined;
isLoading: boolean;
error: unknown;
};
if (isLoading) return ;
if (error) {
return (
);
}
if (!contact) return null;
const name = [contact.firstName, contact.lastName].filter(Boolean).join(" ");
return (
<>
{name && (
{name}
)}
{contact.title && (
{contact.title}
)}
{contact.company && (
{contact.company}
)}
{contact.lifecycleStage && (
{contact.lifecycleStage}
)}
{/* Deals */}
{contact.deals?.length > 0 && (
{t("mail.integrations.deals")}
{contact.deals.map((deal: any) => (
{deal.name}
{deal.amount && (
${Number(deal.amount).toLocaleString()}
)}
{deal.stage && {deal.stage} }
))}
)}
{/* Tickets */}
{contact.tickets?.length > 0 && (
{t("mail.integrations.tickets")}
{contact.tickets.map((ticket: any) => (
{ticket.subject}
{ticket.priority && {ticket.priority} }
{ticket.stage && {ticket.stage} }
))}
)}
>
);
}
// ─── Gong Section ───────────────────────────────────────────────────────────
function GongSection({ email }: { email: string }) {
const t = useT();
const {
data: calls,
isLoading,
error,
} = useGongCalls(email) as {
data: any[] | undefined;
isLoading: boolean;
error: unknown;
};
if (isLoading) return ;
if (!calls || calls.length === 0) {
if (error) {
return (
);
}
return null;
}
return (
<>
{calls.map((call: any) => {
const date = call.started
? new Date(call.started).toLocaleDateString()
: "";
const mins = call.duration ? Math.round(call.duration / 60) : null;
return (
{call.title || "Untitled call"}
{date && {date} }
{mins && {mins}m }
{call.direction && {call.direction} }
);
})}
>
);
}
// ─── Pylon Section ──────────────────────────────────────────────────────────
function PylonSection({ email }: { email: string }) {
const { data, isLoading, error } = usePylonContact(email) as {
data: Record | undefined;
isLoading: boolean;
error: unknown;
};
if (isLoading) return ;
if (!data || (!data.account && data.issues?.length === 0)) {
if (error) {
return (
);
}
return null;
}
return (
<>
{data.account && (
{data.account.name}
{data.account.domain && (
{data.account.domain}
)}
{data.account.type && (
{data.account.type}
)}
)}
{data.issues?.length > 0 && (
Issues
{data.issues.map((issue: any) => {
const stateColor: Record
= {
new: "text-blue-400",
waiting_on_you: "text-yellow-400",
waiting_on_customer: "text-muted-foreground/50",
on_hold: "text-muted-foreground/40",
closed: "text-emerald-400/60",
};
return (
{issue.title}
{issue.state && (
{issue.state.replace(/_/g, " ")}
)}
{issue.assignee && {issue.assignee} }
);
})}
)}
>
);
}
// ─── Shared ─────────────────────────────────────────────────────────────────
function SectionHeader({
logo,
label,
}: {
logo: React.ReactNode;
label: string;
}) {
return (
);
}
function SectionLoading() {
return (
);
}