Skip to content

feat: add Fetch Email-Config button to admin email channel editor#296

Open
snoller wants to merge 8 commits intomainfrom
feat_fetch_email_credentials
Open

feat: add Fetch Email-Config button to admin email channel editor#296
snoller wants to merge 8 commits intomainfrom
feat_fetch_email_credentials

Conversation

@snoller
Copy link
Copy Markdown
Contributor

@snoller snoller commented Apr 13, 2026

Adds a button that calls the HybridAI agent handles API to retrieve email configuration and auto-populate IMAP/SMTP fields.

Adds a button that calls the HybridAI agent handles API to retrieve
email configuration and auto-populate IMAP/SMTP fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 13, 2026 12:56
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds an admin-console action to fetch email channel configuration from the HybridAI “agent handles” API and use it to prefill IMAP/SMTP settings (and optionally store the password as a runtime secret).

Changes:

  • Added a new gateway admin endpoint (/api/admin/email-config/fetch) that proxies HybridAI /api/v1/agent-handles/.
  • Added a “Fetch Email-Config” button in the Email channel editor to populate IMAP/SMTP fields and (if provided) store EMAIL_PASSWORD via runtime secrets.
  • Added a console API client helper for the new fetch endpoint.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.

File Description
src/gateway/gateway-http-server.ts Adds an authenticated admin API route to fetch agent handles from HybridAI and return the payload to the console.
console/src/routes/channels.tsx Adds UI state + handler + button to fetch email config, update draft config, and optionally store EMAIL_PASSWORD.
console/src/api/client.ts Adds fetchEmailConfig() wrapper for the new gateway endpoint.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

const msg =
(payload as Record<string, unknown>)?.message ||
(payload as Record<string, unknown>)?.error ||
`HybridAI API returned HTTP ${response.status}`;
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.

The proxy maps HybridAI HTTP status codes directly back to the console. If HybridAI returns 401/403 (e.g., expired/invalid HYBRIDAI_API_KEY), the console’s requestJson() treats 401 as a gateway-auth failure and clears the stored WEB_API_TOKEN, forcing an unnecessary re-login. Consider mapping upstream 401/403 to a non-401 status (e.g., 502/424) and returning a clear error message instead, while keeping 401 reserved for gateway auth failures.

Suggested change
`HybridAI API returned HTTP ${response.status}`;
`HybridAI API returned HTTP ${response.status}`;
if (response.status === 401 || response.status === 403) {
sendJson(res, 424, {
error: `HybridAI API authentication failed. Check the configured HYBRIDAI_API_KEY. ${String(msg)}`,
});
return;
}

Copilot uses AI. Check for mistakes.
Comment on lines +2542 to +2548
if (!response.ok) {
const msg =
(payload as Record<string, unknown>)?.message ||
(payload as Record<string, unknown>)?.error ||
`HybridAI API returned HTTP ${response.status}`;
sendJson(res, response.status >= 500 ? 502 : response.status, {
error: String(msg),
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.

HybridAI error payloads can be nested (e.g., { error: { message: "..." } }). The current extraction checks only payload.message and payload.error, which can result in sending "[object Object]" or losing the real message. Consider checking payload.error.message (and similar nested shapes) before falling back to a generic HTTP status message.

Suggested change
if (!response.ok) {
const msg =
(payload as Record<string, unknown>)?.message ||
(payload as Record<string, unknown>)?.error ||
`HybridAI API returned HTTP ${response.status}`;
sendJson(res, response.status >= 500 ? 502 : response.status, {
error: String(msg),
const extractHybridAIErrorMessage = (value: unknown): string | null => {
if (typeof value === 'string') {
return value;
}
if (!value || typeof value !== 'object') {
return null;
}
const record = value as Record<string, unknown>;
if (typeof record.message === 'string' && record.message.trim().length > 0) {
return record.message;
}
if (typeof record.error === 'string' && record.error.trim().length > 0) {
return record.error;
}
const nestedError = record.error;
if (nestedError && typeof nestedError === 'object') {
const nestedRecord = nestedError as Record<string, unknown>;
if (
typeof nestedRecord.message === 'string' &&
nestedRecord.message.trim().length > 0
) {
return nestedRecord.message;
}
if (
typeof nestedRecord.error === 'string' &&
nestedRecord.error.trim().length > 0
) {
return nestedRecord.error;
}
}
return null;
};
if (!response.ok) {
const msg =
extractHybridAIErrorMessage(payload) ||
`HybridAI API returned HTTP ${response.status}`;
sendJson(res, response.status >= 500 ? 502 : response.status, {
error: msg,

Copilot uses AI. Check for mistakes.
Comment on lines +2552 to +2554

sendJson(res, 200, payload);
}
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.

This endpoint forwards the raw HybridAI response payload to the browser. If the upstream payload includes credentials (e.g., email_config.password), it can be cached by intermediaries/browsers since sendJson() doesn’t set Cache-Control. Consider setting Cache-Control: no-store for this response and filtering/redacting sensitive fields before returning them to the console.

Copilot uses AI. Check for mistakes.
Comment on lines +1055 to +1062
<button
type="button"
className="btn btn-secondary"
disabled={fetchingEmailConfig}
onClick={handleFetchEmailConfig}
>
{fetchingEmailConfig ? 'Fetching…' : 'Fetch Email-Config'}
</button>
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.

UI button styling appears inconsistent with the rest of the console (this file uses "primary-button"/"ghost-button" patterns, but this introduces "btn btn-secondary"). Consider switching to the existing button classes to match the console’s styling conventions.

Copilot uses AI. Check for mistakes.
Comment on lines +1011 to +1018
toast.success(
`Email config loaded from handle "${withConfig.handle}".`,
);
// If a password was included, save it as runtime secret
if (cfg.password) {
await setRuntimeSecret(props.token, 'EMAIL_PASSWORD', cfg.password);
props.onSecretSaved();
}
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.

toast.success("Email config loaded…") is shown before attempting to save the password to runtime secrets. If setRuntimeSecret fails, the user will still see a success toast even though the operation partially failed. Consider only showing the success toast after all required steps succeed (or splitting into separate toasts for “fields populated” vs “password saved”).

Suggested change
toast.success(
`Email config loaded from handle "${withConfig.handle}".`,
);
// If a password was included, save it as runtime secret
if (cfg.password) {
await setRuntimeSecret(props.token, 'EMAIL_PASSWORD', cfg.password);
props.onSecretSaved();
}
// If a password was included, save it as runtime secret
if (cfg.password) {
await setRuntimeSecret(props.token, 'EMAIL_PASSWORD', cfg.password);
props.onSecretSaved();
}
toast.success(
`Email config loaded from handle "${withConfig.handle}".`,
);

Copilot uses AI. Check for mistakes.
Comment on lines +967 to +1025
async function handleFetchEmailConfig() {
setFetchingEmailConfig(true);
try {
const result = (await fetchEmailConfig(props.token)) as {
handles?: Array<{
handle?: string;
label?: string;
status?: string;
email_config?: {
address?: string;
imap_host?: string;
imap_port?: number;
imap_secure?: boolean;
smtp_host?: string;
smtp_port?: number;
smtp_secure?: boolean;
password?: string;
};
}>;
};

const handles = result?.handles;
if (!Array.isArray(handles) || handles.length === 0) {
toast.info('No agent handles found on HybridAI.');
return;
}

// Find first handle with email_config, or just show what we got
const withConfig = handles.find((h) => h.email_config);
if (withConfig?.email_config) {
const cfg = withConfig.email_config;
props.updateDraft((current) => ({
...current,
email: {
...current.email,
...(cfg.address ? { address: cfg.address } : {}),
...(cfg.imap_host ? { imapHost: cfg.imap_host } : {}),
...(cfg.imap_port != null ? { imapPort: cfg.imap_port } : {}),
...(cfg.imap_secure != null ? { imapSecure: cfg.imap_secure } : {}),
...(cfg.smtp_host ? { smtpHost: cfg.smtp_host } : {}),
...(cfg.smtp_port != null ? { smtpPort: cfg.smtp_port } : {}),
...(cfg.smtp_secure != null ? { smtpSecure: cfg.smtp_secure } : {}),
},
}));
toast.success(
`Email config loaded from handle "${withConfig.handle}".`,
);
// If a password was included, save it as runtime secret
if (cfg.password) {
await setRuntimeSecret(props.token, 'EMAIL_PASSWORD', cfg.password);
props.onSecretSaved();
}
} else {
// No email_config in handles — show available handles info
const summary = handles
.map((h) => `${h.handle} (${h.status})`)
.join(', ');
toast.info(`Handles found: ${summary}. No email config attached.`);
}
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.
snoller and others added 7 commits April 13, 2026 16:07
Returning a 401/403 from the HybridAI proxy caused the console to
interpret it as a gateway auth failure and clear WEB_API_TOKEN.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract error messages from nested shapes like { error: { message } }
to avoid forwarding "[object Object]". Set Cache-Control: no-store on
the proxy response to prevent caching of potentially sensitive data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move the success toast after the setRuntimeSecret call so a partial
failure (fields populated but password save failed) is reported clearly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants