feat: add Fetch Email-Config button to admin email channel editor#296
feat: add Fetch Email-Config button to admin email channel editor#296
Conversation
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>
There was a problem hiding this comment.
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_PASSWORDvia 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}`; |
There was a problem hiding this comment.
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.
| `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; | |
| } |
| 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), |
There was a problem hiding this comment.
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.
| 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, |
|
|
||
| sendJson(res, 200, payload); | ||
| } |
There was a problem hiding this comment.
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.
console/src/routes/channels.tsx
Outdated
| <button | ||
| type="button" | ||
| className="btn btn-secondary" | ||
| disabled={fetchingEmailConfig} | ||
| onClick={handleFetchEmailConfig} | ||
| > | ||
| {fetchingEmailConfig ? 'Fetching…' : 'Fetch Email-Config'} | ||
| </button> |
There was a problem hiding this comment.
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.
console/src/routes/channels.tsx
Outdated
| 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(); | ||
| } |
There was a problem hiding this comment.
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”).
| 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}".`, | |
| ); |
| 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.`); | ||
| } |
There was a problem hiding this comment.
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.
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>
Adds a button that calls the HybridAI agent handles API to retrieve email configuration and auto-populate IMAP/SMTP fields.