diff --git a/console/src/api/client.ts b/console/src/api/client.ts index d5380a60..c9ddb2df 100644 --- a/console/src/api/client.ts +++ b/console/src/api/client.ts @@ -321,6 +321,10 @@ export function fetchConfig(token: string): Promise { return requestJson('/api/admin/config', { token }); } +export function fetchEmailConfig(token: string): Promise { + return requestJson('/api/admin/email-config/fetch', { token }); +} + export function saveConfig( token: string, config: AdminConfig, diff --git a/console/src/api/types.ts b/console/src/api/types.ts index 8dcb9e0c..bbdcc505 100644 --- a/console/src/api/types.ts +++ b/console/src/api/types.ts @@ -21,6 +21,10 @@ export interface GatewayStatus { expiresAt: number | null; reloginRequired: boolean; }; + hybridai?: { + apiKeyConfigured: boolean; + apiKeySource: 'env' | 'runtime-secrets' | null; + }; sandbox?: { mode: 'container' | 'host'; activeSessions: number; diff --git a/console/src/routes/channels.test.tsx b/console/src/routes/channels.test.tsx index 3773ee66..c6f3c154 100644 --- a/console/src/routes/channels.test.tsx +++ b/console/src/routes/channels.test.tsx @@ -12,6 +12,7 @@ import { ToastProvider } from '../components/toast'; import { ChannelsPage } from './channels'; const fetchConfigMock = vi.fn<() => Promise>(); +const fetchEmailConfigMock = vi.fn(); const saveConfigMock = vi.fn(); const setRuntimeSecretMock = vi.fn(); const validateTokenMock = vi.fn(); @@ -19,6 +20,7 @@ const useAuthMock = vi.fn(); vi.mock('../api/client', () => ({ fetchConfig: () => fetchConfigMock(), + fetchEmailConfig: (...args: unknown[]) => fetchEmailConfigMock(...args), saveConfig: (...args: unknown[]) => saveConfigMock(...args), setRuntimeSecret: (...args: unknown[]) => setRuntimeSecretMock(...args), validateToken: (...args: unknown[]) => validateTokenMock(...args), @@ -223,11 +225,16 @@ function renderChannelsPage(): void { describe('ChannelsPage', () => { beforeEach(() => { fetchConfigMock.mockReset(); + fetchEmailConfigMock.mockReset(); saveConfigMock.mockReset(); setRuntimeSecretMock.mockReset(); validateTokenMock.mockReset(); useAuthMock.mockReset(); const gatewayStatus = { + hybridai: { + apiKeyConfigured: false, + apiKeySource: null, + }, discord: { tokenConfigured: false, tokenSource: null, @@ -1034,12 +1041,103 @@ describe('ChannelsPage', () => { renderChannelsPage(); - await screen.findByRole('button', { name: /Email/i }); - fireEvent.click(screen.getByRole('button', { name: /Email/i })); + const [emailChannelButton] = await screen.findAllByRole('button', { + name: /Email/i, + }); + fireEvent.click(emailChannelButton); screen.getByRole('button', { name: 'Change password' }); expect(screen.queryByRole('button', { name: 'Set password' })).toBeNull(); }); + it('hides fetch email config when no HybridAI API key is configured', async () => { + fetchConfigMock.mockResolvedValue({ + path: '/tmp/config.json', + config: makeConfig(), + }); + + renderChannelsPage(); + + const [emailChannelButton] = await screen.findAllByRole('button', { + name: /Email/i, + }); + fireEvent.click(emailChannelButton); + + expect( + screen.queryByRole('button', { name: 'Fetch HybridAI Agent Email' }), + ).toBeNull(); + }); + + it('shows fetch email config when a HybridAI API key is configured', async () => { + fetchConfigMock.mockResolvedValue({ + path: '/tmp/config.json', + config: makeConfig(), + }); + validateTokenMock.mockResolvedValue({ + status: 'ok', + webAuthConfigured: true, + version: 'test', + imageTag: null, + uptime: 1, + sessions: 0, + activeContainers: 0, + defaultModel: 'gpt-5', + ragDefault: true, + timestamp: new Date().toISOString(), + hybridai: { + apiKeyConfigured: true, + apiKeySource: 'runtime-secrets', + }, + email: { + passwordConfigured: false, + passwordSource: null, + }, + imessage: { + passwordConfigured: false, + passwordSource: null, + }, + whatsapp: { + linked: false, + jid: null, + pairingQrText: null, + pairingUpdatedAt: null, + }, + }); + useAuthMock.mockReturnValue({ + token: 'test-token', + gatewayStatus: { + hybridai: { + apiKeyConfigured: true, + apiKeySource: 'runtime-secrets', + }, + email: { + passwordConfigured: false, + passwordSource: null, + }, + imessage: { + passwordConfigured: false, + passwordSource: null, + }, + whatsapp: { + linked: false, + jid: null, + pairingQrText: null, + pairingUpdatedAt: null, + }, + }, + }); + + renderChannelsPage(); + + const [emailChannelButton] = await screen.findAllByRole('button', { + name: /Email/i, + }); + fireEvent.click(emailChannelButton); + + expect( + screen.getByRole('button', { name: 'Fetch HybridAI Agent Email' }), + ).toBeTruthy(); + }); + it('updates Telegram bot tokens through encrypted runtime secrets', async () => { fetchConfigMock.mockResolvedValue({ path: '/tmp/config.json', diff --git a/console/src/routes/channels.tsx b/console/src/routes/channels.tsx index 066680f7..f49f1bc4 100644 --- a/console/src/routes/channels.tsx +++ b/console/src/routes/channels.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { fetchConfig, + fetchEmailConfig, saveConfig, setRuntimeSecret, validateToken, @@ -957,9 +958,89 @@ function EmailChannelEditor(props: { updateDraft: ConfigUpdater; passwordConfigured: boolean; passwordSource: SecretSource; + hybridaiApiKeyConfigured: boolean; token: string; onSecretSaved: () => void; }) { + const [fetchingEmailConfig, setFetchingEmailConfig] = useState(false); + const toast = useToast(); + + async function handleFetchEmailConfig() { + setFetchingEmailConfig(true); + try { + const result = (await fetchEmailConfig(props.token)) as { + handles?: Array<{ + id?: string; + handle?: string; + status?: string; + }>; + credentials?: { + email?: string; + password?: string; + imap_host?: string; + imap_port?: number; + smtp_host?: string; + smtp_port?: number; + } | null; + handleId?: string; + }; + + const handles = result?.handles; + if (!Array.isArray(handles) || handles.length === 0) { + toast.info('No HybridAI agent handles found.'); + return; + } + + const creds = result?.credentials; + if (!creds) { + const summary = handles + .map((h) => `${h.handle} (${h.status})`) + .join(', '); + toast.info( + `Handles found: ${summary}. Could not retrieve mailbox credentials.`, + ); + return; + } + + props.updateDraft((current) => ({ + ...current, + email: { + ...current.email, + ...(creds.email ? { address: creds.email } : {}), + ...(creds.imap_host ? { imapHost: creds.imap_host } : {}), + ...(creds.imap_port != null ? { imapPort: creds.imap_port } : {}), + ...(creds.smtp_host ? { smtpHost: creds.smtp_host } : {}), + ...(creds.smtp_port != null ? { smtpPort: creds.smtp_port } : {}), + }, + })); + + // Save password as runtime secret before showing success + if (creds.password) { + try { + await setRuntimeSecret( + props.token, + 'EMAIL_PASSWORD', + creds.password, + ); + props.onSecretSaved(); + } catch (err) { + toast.error('Password could not be saved', getErrorMessage(err)); + toast.info( + 'Email fields were populated, but password was not saved.', + ); + return; + } + } + + const label = result.handleId || 'HybridAI'; + toast.success(`Email config loaded from ${label}.`); + } catch (error) { + toast.error('Failed to fetch email config', getErrorMessage(error)); + } finally { + setFetchingEmailConfig(false); + } + } + return ( <> + {props.hybridaiApiKeyConfigured ? ( +
+ +
+ ) : null} +