Skip to content
4 changes: 4 additions & 0 deletions console/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,10 @@ export function fetchConfig(token: string): Promise<AdminConfigResponse> {
return requestJson<AdminConfigResponse>('/api/admin/config', { token });
}

export function fetchEmailConfig(token: string): Promise<unknown> {
return requestJson<unknown>('/api/admin/email-config/fetch', { token });
}

export function saveConfig(
token: string,
config: AdminConfig,
Expand Down
4 changes: 4 additions & 0 deletions console/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
102 changes: 100 additions & 2 deletions console/src/routes/channels.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ import { ToastProvider } from '../components/toast';
import { ChannelsPage } from './channels';

const fetchConfigMock = vi.fn<() => Promise<AdminConfigResponse>>();
const fetchEmailConfigMock = vi.fn();
const saveConfigMock = vi.fn();
const setRuntimeSecretMock = vi.fn();
const validateTokenMock = vi.fn();
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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
99 changes: 99 additions & 0 deletions console/src/routes/channels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import {
fetchConfig,
fetchEmailConfig,
saveConfig,
setRuntimeSecret,
validateToken,
Expand Down Expand Up @@ -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;
}
Comment on lines +968 to +1033
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior (Fetch Email-Config button + draft updates + optional runtime secret write) is introduced here, but there are existing tests for this page (console/src/routes/channels.test.tsx) and none cover this flow. Please add tests for: successful fetch populating IMAP/SMTP fields; no handles / no email_config cases; and password save path calling setRuntimeSecret.

Copilot uses AI. Check for mistakes.

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 (
<>
<BooleanField
Expand All @@ -978,6 +1059,19 @@ function EmailChannelEditor(props: {
}
/>

{props.hybridaiApiKeyConfigured ? (
<div className="button-row">
<button
type="button"
className="ghost-button"
disabled={fetchingEmailConfig}
onClick={handleFetchEmailConfig}
>
{fetchingEmailConfig ? 'Fetching…' : 'Fetch HybridAI Agent Email'}
</button>
</div>
) : null}

<div className="field-grid">
<label className="field">
<span>Address</span>
Expand Down Expand Up @@ -1967,6 +2061,7 @@ function renderSelectedEditor(
source: SecretSource;
};
},
hybridaiApiKeyConfigured: boolean,
whatsappStatus: {
linked: boolean;
pairingQrText: string | null;
Expand Down Expand Up @@ -2025,6 +2120,7 @@ function renderSelectedEditor(
updateDraft={updateDraft}
passwordConfigured={secretStatus.email.configured}
passwordSource={secretStatus.email.source}
hybridaiApiKeyConfigured={hybridaiApiKeyConfigured}
token={token}
onSecretSaved={onSecretSaved}
/>
Expand Down Expand Up @@ -2150,6 +2246,8 @@ export function ChannelsPage() {
linked: statusQuery.data?.whatsapp?.linked ?? false,
pairingQrText: statusQuery.data?.whatsapp?.pairingQrText ?? null,
};
const hybridaiApiKeyConfigured =
statusQuery.data?.hybridai?.apiKeyConfigured ?? false;

return (
<div className="page-stack">
Expand Down Expand Up @@ -2200,6 +2298,7 @@ export function ChannelsPage() {
updateDraft,
auth.token,
secretStatus,
hybridaiApiKeyConfigured,
whatsappStatus,
() => {
void queryClient.invalidateQueries({
Expand Down
Loading
Loading