mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
@@ -0,0 +1,34 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FALLBACK_DEFINITIONS } from '../../lib/channels/definitions';
|
||||
import { renderWithProviders } from '../../test/test-utils';
|
||||
import ChannelConfigPanel from './ChannelConfigPanel';
|
||||
|
||||
// The credential form owns its own RPC/redux wiring (covered by its own suite);
|
||||
// stub it so this test isolates the panel's channel→component routing.
|
||||
vi.mock('./CredentialChannelConfig', () => ({
|
||||
default: ({ definition }: { definition: { id: string } }) => (
|
||||
<div data-testid="credential-config">{definition.id}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('<ChannelConfigPanel />', () => {
|
||||
it('routes the email channel to the credential form (#4280)', () => {
|
||||
renderWithProviders(
|
||||
<ChannelConfigPanel selectedChannel="email" definitions={FALLBACK_DEFINITIONS} />
|
||||
);
|
||||
const form = screen.getByTestId('credential-config');
|
||||
expect(form).toHaveTextContent('email');
|
||||
});
|
||||
|
||||
it('routes lark and dingtalk to the same credential form', () => {
|
||||
for (const channel of ['lark', 'dingtalk'] as const) {
|
||||
const { unmount } = renderWithProviders(
|
||||
<ChannelConfigPanel selectedChannel={channel} definitions={FALLBACK_DEFINITIONS} />
|
||||
);
|
||||
expect(screen.getByTestId('credential-config')).toHaveTextContent(channel);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -47,9 +47,9 @@ const ChannelConfigPanel = ({ selectedChannel, definitions }: ChannelConfigPanel
|
||||
{selectedChannel === 'telegram' && <TelegramConfig definition={definition} />}
|
||||
{selectedChannel === 'discord' && <DiscordConfig definition={definition} />}
|
||||
{selectedChannel === 'web' && <WebChannelConfig definition={definition} />}
|
||||
{(selectedChannel === 'lark' || selectedChannel === 'dingtalk') && (
|
||||
<CredentialChannelConfig definition={definition} />
|
||||
)}
|
||||
{(selectedChannel === 'lark' ||
|
||||
selectedChannel === 'dingtalk' ||
|
||||
selectedChannel === 'email') && <CredentialChannelConfig definition={definition} />}
|
||||
</section>
|
||||
|
||||
<ChannelCapabilities capabilities={definition.capabilities} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { renderWithProviders } from '../../test/test-utils';
|
||||
import ChannelSetupModal from './ChannelSetupModal';
|
||||
|
||||
const larkDefinition = FALLBACK_DEFINITIONS.find(def => def.id === 'lark')!;
|
||||
const emailDefinition = FALLBACK_DEFINITIONS.find(def => def.id === 'email')!;
|
||||
|
||||
describe('<ChannelSetupModal /> header logo (issue #2854)', () => {
|
||||
it('renders the Lark / Feishu brand logo in the modal header', () => {
|
||||
@@ -12,3 +13,15 @@ describe('<ChannelSetupModal /> header logo (issue #2854)', () => {
|
||||
expect(document.querySelector('img[src="/lark.png"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('<ChannelSetupModal /> credential-channel routing (#4280)', () => {
|
||||
it('renders the credential form for email instead of "config not available"', () => {
|
||||
const { getByPlaceholderText, queryByText } = renderWithProviders(
|
||||
<ChannelSetupModal definition={emailDefinition} onClose={vi.fn()} />
|
||||
);
|
||||
// The reused credential form renders the IMAP host field…
|
||||
expect(getByPlaceholderText('imap.fastmail.com')).toBeInTheDocument();
|
||||
// …and does not fall through to the not-available placeholder.
|
||||
expect(queryByText(/config not available/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { ChannelDefinition, ChannelType } from '../../types/channels';
|
||||
import { CloseIcon } from '../ui';
|
||||
import Button from '../ui/Button';
|
||||
import { renderChannelIcon } from './channelIcon';
|
||||
import CredentialChannelConfig from './CredentialChannelConfig';
|
||||
import DiscordConfig from './DiscordConfig';
|
||||
import TelegramConfig from './TelegramConfig';
|
||||
import YuanbaoConfig from './YuanbaoConfig';
|
||||
@@ -30,6 +31,13 @@ function ChannelConfigContent({ definition }: { definition: ChannelDefinition })
|
||||
return <DiscordConfig definition={definition} />;
|
||||
case 'yuanbao':
|
||||
return <YuanbaoConfig definition={definition} />;
|
||||
// Credential-form channels (Lark/DingTalk/Email) render the same generic
|
||||
// form here as on the Channels page — otherwise clicking their Skills-grid
|
||||
// tile fell through to "config not available" (#4280 review).
|
||||
case 'lark':
|
||||
case 'dingtalk':
|
||||
case 'email':
|
||||
return <CredentialChannelConfig definition={definition} />;
|
||||
default:
|
||||
return (
|
||||
<p className="text-sm text-content-faint py-4">
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock('../../utils/tauriCommands/core', () => ({ restartCoreProcess: vi.fn() }
|
||||
|
||||
const larkDefinition = FALLBACK_DEFINITIONS.find(def => def.id === 'lark')!;
|
||||
const dingtalkDefinition = FALLBACK_DEFINITIONS.find(def => def.id === 'dingtalk')!;
|
||||
const emailDefinition = FALLBACK_DEFINITIONS.find(def => def.id === 'email')!;
|
||||
|
||||
const connectChannelMock = vi.mocked(channelConnectionsApi.connectChannel);
|
||||
const disconnectChannelMock = vi.mocked(channelConnectionsApi.disconnectChannel);
|
||||
@@ -47,9 +48,11 @@ describe('<CredentialChannelConfig />', () => {
|
||||
fireEvent.click(screen.getByText('Connect'));
|
||||
|
||||
await waitFor(() => expect(connectChannelMock).toHaveBeenCalledTimes(1));
|
||||
// Booleans are always submitted (use_feishu defaults off) so a default-on
|
||||
// field can be turned off from the form; strings only when filled.
|
||||
expect(connectChannelMock).toHaveBeenCalledWith('lark', {
|
||||
authMode: 'api_key',
|
||||
credentials: { app_id: 'cli_abc123', app_secret: 'shh-secret' },
|
||||
credentials: { app_id: 'cli_abc123', app_secret: 'shh-secret', use_feishu: 'false' },
|
||||
});
|
||||
await waitFor(() => expect(restartCoreProcessMock).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
@@ -127,4 +130,68 @@ describe('<CredentialChannelConfig />', () => {
|
||||
await waitFor(() => expect(disconnectChannelMock).toHaveBeenCalledTimes(1));
|
||||
expect(disconnectChannelMock).toHaveBeenCalledWith('dingtalk', 'api_key');
|
||||
});
|
||||
|
||||
it('renders and connects the native IMAP/SMTP email channel (#4280)', async () => {
|
||||
renderWithProviders(<CredentialChannelConfig definition={emailDefinition} />);
|
||||
|
||||
// The reused form renders the email server fields from the definition,
|
||||
// including the TLS boolean as a checkbox.
|
||||
expect(screen.getByPlaceholderText('imap.fastmail.com')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('smtp.fastmail.com')).toBeInTheDocument();
|
||||
expect(screen.getByRole('checkbox')).toBeInTheDocument(); // smtp_tls
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('imap.fastmail.com'), {
|
||||
target: { value: 'imap.fastmail.com' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('you@example.com'), {
|
||||
target: { value: 'me@fastmail.com' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('App-specific password (recommended)'), {
|
||||
target: { value: 'fmapp-pass' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('smtp.fastmail.com'), {
|
||||
target: { value: 'smtp.fastmail.com' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Connect'));
|
||||
|
||||
await waitFor(() => expect(connectChannelMock).toHaveBeenCalledTimes(1));
|
||||
// smtp_tls is a default-on boolean: it is submitted as 'true' even when the
|
||||
// (pre-checked) box is left untouched, so the persisted value matches the UI.
|
||||
expect(connectChannelMock).toHaveBeenCalledWith('email', {
|
||||
authMode: 'api_key',
|
||||
credentials: {
|
||||
imap_host: 'imap.fastmail.com',
|
||||
username: 'me@fastmail.com',
|
||||
password: 'fmapp-pass',
|
||||
smtp_host: 'smtp.fastmail.com',
|
||||
smtp_tls: 'true',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('lets the user turn smtp_tls off from the pre-checked box (#4280 review)', async () => {
|
||||
renderWithProviders(<CredentialChannelConfig definition={emailDefinition} />);
|
||||
|
||||
// Default-on: the box renders checked before any interaction.
|
||||
const tls = screen.getByRole('checkbox') as HTMLInputElement;
|
||||
expect(tls.checked).toBe(true);
|
||||
fireEvent.click(tls); // turn TLS off
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('imap.fastmail.com'), {
|
||||
target: { value: 'mail.self.host' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('you@example.com'), {
|
||||
target: { value: 'me@self.host' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('App-specific password (recommended)'), {
|
||||
target: { value: 'pw' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('smtp.fastmail.com'), {
|
||||
target: { value: 'mail.self.host' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Connect'));
|
||||
|
||||
await waitFor(() => expect(connectChannelMock).toHaveBeenCalledTimes(1));
|
||||
expect(connectChannelMock.mock.calls[0][1].credentials?.smtp_tls).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,11 +60,19 @@ const CredentialChannelConfig = ({ definition }: CredentialChannelConfigProps) =
|
||||
|
||||
const credentials: Record<string, string> = {};
|
||||
for (const field of spec.fields) {
|
||||
const raw = fieldValues[compositeKey]?.[field.key] ?? '';
|
||||
const val = field.field_type === 'boolean' ? raw : raw.trim();
|
||||
// Booleans are always semantically set (checkbox is on or off), so an
|
||||
// untouched required boolean must not fail the empty-value check.
|
||||
if (field.required && field.field_type !== 'boolean' && !val) {
|
||||
// Booleans are always semantically set (checkbox is on or off). Submit
|
||||
// them unconditionally, seeding an untouched box from its declared
|
||||
// default, so a default-on field like smtp_tls can actually be turned
|
||||
// off from the form instead of silently reverting to the default.
|
||||
if (field.field_type === 'boolean') {
|
||||
const raw = fieldValues[compositeKey]?.[field.key];
|
||||
const on =
|
||||
raw === undefined || raw === '' ? (field.default_bool ?? false) : raw === 'true';
|
||||
credentials[field.key] = on ? 'true' : 'false';
|
||||
continue;
|
||||
}
|
||||
const val = (fieldValues[compositeKey]?.[field.key] ?? '').trim();
|
||||
if (field.required && !val) {
|
||||
const label = t(`channels.${channel}.fields.${field.key}.label`, field.label);
|
||||
dispatch(
|
||||
setChannelConnectionStatus({
|
||||
|
||||
@@ -101,7 +101,13 @@ export function ChannelAuthFields({
|
||||
<ChannelFieldInput
|
||||
key={field.key}
|
||||
field={mapped}
|
||||
value={fieldValues[compositeKey]?.[field.key] ?? ''}
|
||||
value={
|
||||
fieldValues[compositeKey]?.[field.key] ??
|
||||
// Seed a boolean's checkbox from its declared default so the
|
||||
// visible state matches what persists when left untouched
|
||||
// (e.g. smtp_tls defaults on). Non-booleans default to blank.
|
||||
(field.field_type === 'boolean' ? String(field.default_bool ?? false) : '')
|
||||
}
|
||||
onChange={val => onChange(compositeKey, field.key, val)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('renderChannelIcon', () => {
|
||||
['discord', '🎮'],
|
||||
['web', '🌐'],
|
||||
['mcp', '🔌'],
|
||||
['email', '✉️'],
|
||||
])('renders the %s channel as its emoji glyph', (icon, glyph) => {
|
||||
const { getByTestId } = renderIcon(icon);
|
||||
expect(getByTestId('icon-host')).toHaveTextContent(glyph);
|
||||
@@ -64,6 +65,7 @@ describe('renderChannelIcon', () => {
|
||||
'imessage',
|
||||
'lark',
|
||||
'dingtalk',
|
||||
'email',
|
||||
'yuanbao',
|
||||
];
|
||||
const supportedIcons = [
|
||||
|
||||
@@ -21,7 +21,13 @@ const ICON_COMPONENTS: Record<string, (props: { className?: string }) => ReactEl
|
||||
* Emoji icons for channels without a dedicated brand mark, rendered as plain
|
||||
* text. Keyed by `ChannelDefinition.icon`.
|
||||
*/
|
||||
const ICON_EMOJI: Record<string, string> = { telegram: '✈️', discord: '🎮', web: '🌐', mcp: '🔌' };
|
||||
const ICON_EMOJI: Record<string, string> = {
|
||||
telegram: '✈️',
|
||||
discord: '🎮',
|
||||
web: '🌐',
|
||||
mcp: '🔌',
|
||||
email: '✉️',
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the brand icon for a channel, keyed by `ChannelDefinition.icon`.
|
||||
|
||||
@@ -257,4 +257,94 @@ export const FALLBACK_DEFINITIONS: ChannelDefinition[] = [
|
||||
],
|
||||
capabilities: ['send_text', 'receive_text'],
|
||||
},
|
||||
// Native IMAP/SMTP email (#4280). Field keys map 1:1 to
|
||||
// `config::schema::channels::EmailConfig` and `email_definition()` in
|
||||
// `src/openhuman/channels/controllers/definitions.rs`; keep the two in sync.
|
||||
{
|
||||
id: 'email',
|
||||
display_name: 'Email (IMAP/SMTP)',
|
||||
description: 'Send and receive email via any standard IMAP/SMTP mailbox.',
|
||||
icon: 'email',
|
||||
auth_modes: [
|
||||
{
|
||||
mode: 'api_key',
|
||||
description: "Provide your mailbox's IMAP/SMTP server settings and an app password.",
|
||||
fields: [
|
||||
{
|
||||
key: 'imap_host',
|
||||
label: 'IMAP Host',
|
||||
field_type: 'string',
|
||||
required: true,
|
||||
placeholder: 'imap.fastmail.com',
|
||||
},
|
||||
{
|
||||
key: 'imap_port',
|
||||
label: 'IMAP Port',
|
||||
field_type: 'string',
|
||||
required: false,
|
||||
placeholder: '993 (TLS)',
|
||||
},
|
||||
{
|
||||
key: 'username',
|
||||
label: 'Email Address',
|
||||
field_type: 'string',
|
||||
required: true,
|
||||
placeholder: 'you@example.com',
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
label: 'Password / App Password',
|
||||
field_type: 'secret',
|
||||
required: true,
|
||||
placeholder: 'App-specific password (recommended)',
|
||||
},
|
||||
{
|
||||
key: 'smtp_host',
|
||||
label: 'SMTP Host',
|
||||
field_type: 'string',
|
||||
required: true,
|
||||
placeholder: 'smtp.fastmail.com',
|
||||
},
|
||||
{
|
||||
key: 'smtp_port',
|
||||
label: 'SMTP Port',
|
||||
field_type: 'string',
|
||||
required: false,
|
||||
placeholder: '465 (TLS)',
|
||||
},
|
||||
{
|
||||
key: 'smtp_tls',
|
||||
label: 'Use TLS for SMTP',
|
||||
field_type: 'boolean',
|
||||
required: false,
|
||||
placeholder: 'On = TLS (recommended)',
|
||||
default_bool: true,
|
||||
},
|
||||
{
|
||||
key: 'from_address',
|
||||
label: 'From Address',
|
||||
field_type: 'string',
|
||||
required: false,
|
||||
placeholder: 'Optional — defaults to the email address above',
|
||||
},
|
||||
{
|
||||
key: 'imap_folder',
|
||||
label: 'IMAP Folder',
|
||||
field_type: 'string',
|
||||
required: false,
|
||||
placeholder: 'Optional — defaults to INBOX',
|
||||
},
|
||||
{
|
||||
key: 'allowed_senders',
|
||||
label: 'Allowed Senders',
|
||||
field_type: 'string',
|
||||
required: false,
|
||||
placeholder: 'Comma-separated addresses or @domain; * to allow any',
|
||||
},
|
||||
],
|
||||
auth_action: undefined,
|
||||
},
|
||||
],
|
||||
capabilities: ['send_text', 'receive_text', 'file_attachments'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -31,6 +31,8 @@ const initialState: ChannelConnectionsState = {
|
||||
// populates them when the user wires up credentials.
|
||||
lark: makeEmptyChannelModes(),
|
||||
dingtalk: makeEmptyChannelModes(),
|
||||
// Native IMAP/SMTP email channel (#4280).
|
||||
email: makeEmptyChannelModes(),
|
||||
// MCP Servers tab is a virtual channel — no auth-mode connections,
|
||||
// but must be present to satisfy Record<ChannelType, …>.
|
||||
mcp: makeEmptyChannelModes(),
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
export type ChannelType = 'telegram' | 'discord' | 'web' | 'lark' | 'dingtalk' | 'mcp' | 'yuanbao';
|
||||
export type ChannelType =
|
||||
| 'telegram'
|
||||
| 'discord'
|
||||
| 'web'
|
||||
| 'lark'
|
||||
| 'dingtalk'
|
||||
| 'email'
|
||||
| 'mcp'
|
||||
| 'yuanbao';
|
||||
|
||||
/** Every valid {@link ChannelType}, for runtime validation of values that arrive
|
||||
* from the core (which is typed `string`). `satisfies` keeps this list in
|
||||
@@ -10,6 +18,7 @@ export const KNOWN_CHANNEL_TYPES = [
|
||||
'web',
|
||||
'lark',
|
||||
'dingtalk',
|
||||
'email',
|
||||
'mcp',
|
||||
'yuanbao',
|
||||
] as const satisfies readonly ChannelType[];
|
||||
@@ -62,6 +71,9 @@ export interface FieldRequirement {
|
||||
field_type: string; // "string" | "secret" | "boolean"
|
||||
required: boolean;
|
||||
placeholder: string;
|
||||
/** Default state for boolean fields; seeds the checkbox so its visible state
|
||||
* matches what persists when untouched (e.g. smtp_tls defaults on). */
|
||||
default_bool?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthModeSpec {
|
||||
|
||||
@@ -400,6 +400,7 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
|
||||
| 10.1.3 | Gmail Connection | WD | `gmail-flow.spec.ts` | ✅ | |
|
||||
| 10.1.4 | Slack Connection | WD | `app/test/e2e/specs/slack-flow.spec.ts` | ✅ | Was ❌ |
|
||||
| 10.1.5 | Yuanbao Connection | RU | `src/openhuman/channels/providers/yuanbao/`, `src/openhuman/channels/controllers/ops.rs::tests::connect_yuanbao_*`, `src/openhuman/channels/runtime/startup.rs::yuanbao_secret_tests` | 🟡 | New API-key channel for Tencent Yuanbao. RU covers sign-token preflight (valid/invalid creds, env-override cluster routing), credentials store hydration (incl. stale app_key guard), and WS reconnect/shutdown. No WDIO spec yet — connect-flow UI is rendered via the generic `ChannelSetupModal` already exercised by other channel flow specs. |
|
||||
| 10.1.6 | Email (IMAP/SMTP) Connection | RU+VU | `src/openhuman/channels/controllers/definitions_tests.rs::email_*`, `src/openhuman/channels/controllers/ops/connect.rs::email_config_tests`, `src/openhuman/channels/controllers/ops_tests.rs::{persist_email_config_*,disconnect_email_*,connect_email_rejects_invalid_port_*,test_channel_email_rejects_invalid_port_*}`, `app/src/components/channels/CredentialChannelConfig.test.tsx`, `app/src/components/channels/ChannelConfigPanel.test.tsx` | 🟡 | #4280 — native IMAP/SMTP for non-Gmail/Outlook mailboxes surfacing the existing `EmailChannel`. RU covers credentials→`EmailConfig` mapping/defaults, port/sender parsing, definition/validation, config persist + disconnect, and pre-network invalid-port rejection. VU covers the connect form rendering/submit + panel routing. Live IMAP verify + WDIO connect-flow are follow-ups. |
|
||||
|
||||
### 10.2 Authentication & Authorization
|
||||
|
||||
|
||||
@@ -57,6 +57,12 @@ pub struct FieldRequirement {
|
||||
pub required: bool,
|
||||
/// Placeholder / help text.
|
||||
pub placeholder: &'static str,
|
||||
/// Default state for `field_type == "boolean"` fields. The UI seeds the
|
||||
/// checkbox from this so its visible state matches what persists when the
|
||||
/// user doesn't touch it (e.g. `smtp_tls` defaults on). `None` for
|
||||
/// non-boolean fields and booleans that default off.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_bool: Option<bool>,
|
||||
}
|
||||
|
||||
/// Describes one auth mode a channel supports.
|
||||
@@ -160,6 +166,7 @@ pub fn all_channel_definitions() -> Vec<ChannelDefinition> {
|
||||
imessage_definition(),
|
||||
lark_definition(),
|
||||
dingtalk_definition(),
|
||||
email_definition(),
|
||||
yuanbao_definition(),
|
||||
]
|
||||
}
|
||||
@@ -194,6 +201,7 @@ fn telegram_definition() -> ChannelDefinition {
|
||||
field_type: "secret",
|
||||
required: true,
|
||||
placeholder: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "chat_id",
|
||||
@@ -201,6 +209,7 @@ fn telegram_definition() -> ChannelDefinition {
|
||||
field_type: "string",
|
||||
required: false,
|
||||
placeholder: "Optional: default chat for outbound messages",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "allowed_users",
|
||||
@@ -208,6 +217,7 @@ fn telegram_definition() -> ChannelDefinition {
|
||||
field_type: "string",
|
||||
required: false,
|
||||
placeholder: "Comma-separated Telegram usernames",
|
||||
default_bool: None,
|
||||
},
|
||||
],
|
||||
auth_action: None,
|
||||
@@ -239,6 +249,7 @@ fn discord_definition() -> ChannelDefinition {
|
||||
field_type: "secret",
|
||||
required: true,
|
||||
placeholder: "Your Discord bot token",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "guild_id",
|
||||
@@ -246,6 +257,7 @@ fn discord_definition() -> ChannelDefinition {
|
||||
field_type: "string",
|
||||
required: false,
|
||||
placeholder: "Optional: restrict to a specific server",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "channel_id",
|
||||
@@ -253,6 +265,7 @@ fn discord_definition() -> ChannelDefinition {
|
||||
field_type: "string",
|
||||
required: false,
|
||||
placeholder: "Optional: default channel for outbound messages",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "allowed_users",
|
||||
@@ -261,6 +274,7 @@ fn discord_definition() -> ChannelDefinition {
|
||||
required: false,
|
||||
placeholder:
|
||||
"Comma-separated Discord user IDs, or * for everyone (blank = everyone)",
|
||||
default_bool: None,
|
||||
},
|
||||
],
|
||||
auth_action: None,
|
||||
@@ -322,6 +336,7 @@ fn imessage_definition() -> ChannelDefinition {
|
||||
field_type: "string",
|
||||
required: false,
|
||||
placeholder: "Comma-separated phone numbers or emails; * to allow any",
|
||||
default_bool: None,
|
||||
}],
|
||||
auth_action: None,
|
||||
}],
|
||||
@@ -353,6 +368,7 @@ fn lark_definition() -> ChannelDefinition {
|
||||
field_type: "string",
|
||||
required: true,
|
||||
placeholder: "cli_xxxxxxxxxxxx",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "app_secret",
|
||||
@@ -360,6 +376,7 @@ fn lark_definition() -> ChannelDefinition {
|
||||
field_type: "secret",
|
||||
required: true,
|
||||
placeholder: "Your Lark app secret",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "encrypt_key",
|
||||
@@ -367,6 +384,7 @@ fn lark_definition() -> ChannelDefinition {
|
||||
field_type: "secret",
|
||||
required: false,
|
||||
placeholder: "Optional — required only if you enabled message encryption",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "verification_token",
|
||||
@@ -374,6 +392,7 @@ fn lark_definition() -> ChannelDefinition {
|
||||
field_type: "secret",
|
||||
required: false,
|
||||
placeholder: "Optional — used for HTTP webhook verification",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "use_feishu",
|
||||
@@ -381,6 +400,7 @@ fn lark_definition() -> ChannelDefinition {
|
||||
field_type: "boolean",
|
||||
required: false,
|
||||
placeholder: "On = open.feishu.cn (China); off = open.larksuite.com",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "receive_mode",
|
||||
@@ -388,6 +408,7 @@ fn lark_definition() -> ChannelDefinition {
|
||||
field_type: "string",
|
||||
required: false,
|
||||
placeholder: "websocket (default) or webhook",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "port",
|
||||
@@ -401,6 +422,7 @@ fn lark_definition() -> ChannelDefinition {
|
||||
required: false,
|
||||
placeholder:
|
||||
"Optional — local HTTP port when receive_mode = webhook (e.g. 8080)",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "allowed_users",
|
||||
@@ -408,6 +430,7 @@ fn lark_definition() -> ChannelDefinition {
|
||||
field_type: "string",
|
||||
required: false,
|
||||
placeholder: "Comma-separated open_id / union_id; leave empty to allow any",
|
||||
default_bool: None,
|
||||
},
|
||||
],
|
||||
auth_action: None,
|
||||
@@ -438,6 +461,7 @@ fn dingtalk_definition() -> ChannelDefinition {
|
||||
field_type: "string",
|
||||
required: true,
|
||||
placeholder: "ding_xxxxxxxxxxxx",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "client_secret",
|
||||
@@ -445,6 +469,7 @@ fn dingtalk_definition() -> ChannelDefinition {
|
||||
field_type: "secret",
|
||||
required: true,
|
||||
placeholder: "Your DingTalk app secret",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "allowed_users",
|
||||
@@ -452,6 +477,7 @@ fn dingtalk_definition() -> ChannelDefinition {
|
||||
field_type: "string",
|
||||
required: false,
|
||||
placeholder: "Comma-separated DingTalk userIds; leave empty to allow any",
|
||||
default_bool: None,
|
||||
},
|
||||
],
|
||||
auth_action: None,
|
||||
@@ -460,6 +486,119 @@ fn dingtalk_definition() -> ChannelDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
/// Native IMAP/SMTP email channel for any standard mailbox (Fastmail, Proton
|
||||
/// Bridge, iCloud, self-hosted, …) — the option non-Gmail/non-Outlook users
|
||||
/// lacked (#4280). The IMAP IDLE + SMTP wire-protocol already lives in
|
||||
/// `src/openhuman/channels/providers/email_channel.rs`; this definition exposes
|
||||
/// its config surface to the Connections UI so users no longer need to
|
||||
/// hand-edit `config.toml`.
|
||||
///
|
||||
/// Field keys map 1:1 to `config::schema::channels::EmailConfig` so the
|
||||
/// frontend persists credentials through the same `channels_connect` RPC every
|
||||
/// other channel uses. `imap_port` / `smtp_port` are typed as plain strings
|
||||
/// (FieldRequirement only supports string/secret/boolean); `connect_channel`
|
||||
/// parses them back to `u16`.
|
||||
fn email_definition() -> ChannelDefinition {
|
||||
ChannelDefinition {
|
||||
id: "email",
|
||||
display_name: "Email (IMAP/SMTP)",
|
||||
description: "Send and receive email via any standard IMAP/SMTP mailbox.",
|
||||
icon: "email",
|
||||
auth_modes: vec![AuthModeSpec {
|
||||
mode: ChannelAuthMode::ApiKey,
|
||||
description: "Provide your mailbox's IMAP/SMTP server settings and an app password.",
|
||||
fields: vec![
|
||||
FieldRequirement {
|
||||
key: "imap_host",
|
||||
label: "IMAP Host",
|
||||
field_type: "string",
|
||||
required: true,
|
||||
placeholder: "imap.fastmail.com",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "imap_port",
|
||||
label: "IMAP Port",
|
||||
field_type: "string",
|
||||
required: false,
|
||||
placeholder: "993 (TLS)",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "username",
|
||||
label: "Email Address",
|
||||
field_type: "string",
|
||||
required: true,
|
||||
placeholder: "you@example.com",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "password",
|
||||
label: "Password / App Password",
|
||||
field_type: "secret",
|
||||
required: true,
|
||||
placeholder: "App-specific password (recommended)",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "smtp_host",
|
||||
label: "SMTP Host",
|
||||
field_type: "string",
|
||||
required: true,
|
||||
placeholder: "smtp.fastmail.com",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "smtp_port",
|
||||
label: "SMTP Port",
|
||||
field_type: "string",
|
||||
required: false,
|
||||
placeholder: "465 (TLS)",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "smtp_tls",
|
||||
label: "Use TLS for SMTP",
|
||||
field_type: "boolean",
|
||||
required: false,
|
||||
placeholder: "On = TLS (recommended)",
|
||||
default_bool: Some(true),
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "from_address",
|
||||
label: "From Address",
|
||||
field_type: "string",
|
||||
required: false,
|
||||
placeholder: "Optional — defaults to the email address above",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "imap_folder",
|
||||
label: "IMAP Folder",
|
||||
field_type: "string",
|
||||
required: false,
|
||||
placeholder: "Optional — defaults to INBOX",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "allowed_senders",
|
||||
label: "Allowed Senders",
|
||||
field_type: "string",
|
||||
required: false,
|
||||
placeholder: "Comma-separated addresses or @domain; * to allow any",
|
||||
default_bool: None,
|
||||
},
|
||||
],
|
||||
auth_action: None,
|
||||
}],
|
||||
capabilities: vec![
|
||||
ChannelCapability::SendText,
|
||||
ChannelCapability::ReceiveText,
|
||||
ChannelCapability::FileAttachments,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn yuanbao_definition() -> ChannelDefinition {
|
||||
// Endpoint URLs (api_domain / ws_domain) are not user-facing — the
|
||||
// channel derives them from the `env` field of `YuanbaoConfig`
|
||||
@@ -479,6 +618,7 @@ fn yuanbao_definition() -> ChannelDefinition {
|
||||
field_type: "string",
|
||||
required: true,
|
||||
placeholder: "元宝开放平台 AppID",
|
||||
default_bool: None,
|
||||
},
|
||||
FieldRequirement {
|
||||
key: "app_secret",
|
||||
@@ -486,6 +626,7 @@ fn yuanbao_definition() -> ChannelDefinition {
|
||||
field_type: "secret",
|
||||
required: true,
|
||||
placeholder: "元宝开放平台 AppSecret",
|
||||
default_bool: None,
|
||||
},
|
||||
],
|
||||
auth_action: None,
|
||||
|
||||
@@ -366,3 +366,82 @@ fn auth_mode_serializes_to_expected_wire_values() {
|
||||
ChannelAuthMode::ManagedDm
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_definition_is_registered() {
|
||||
let def = find_channel_definition("email").expect("email channel not registered");
|
||||
assert_eq!(def.display_name, "Email (IMAP/SMTP)");
|
||||
assert!(def.capabilities.contains(&ChannelCapability::SendText));
|
||||
assert!(def.capabilities.contains(&ChannelCapability::ReceiveText));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_definition_field_shape_matches_email_config() {
|
||||
let def = find_channel_definition("email").expect("email not found");
|
||||
let spec = def
|
||||
.auth_mode_spec(ChannelAuthMode::ApiKey)
|
||||
.expect("email must expose an api_key auth mode");
|
||||
|
||||
// Required fields — these gate a usable IMAP/SMTP connection.
|
||||
for key in ["imap_host", "username", "password", "smtp_host"] {
|
||||
let field = spec
|
||||
.fields
|
||||
.iter()
|
||||
.find(|f| f.key == key)
|
||||
.unwrap_or_else(|| panic!("email spec missing required field: {key}"));
|
||||
assert!(field.required, "email field {key} must be required");
|
||||
}
|
||||
// Password must be secret-typed so the UI masks it.
|
||||
let password = spec.fields.iter().find(|f| f.key == "password").unwrap();
|
||||
assert_eq!(password.field_type, "secret");
|
||||
|
||||
// Optional fields — keys map 1:1 to `EmailConfig` in
|
||||
// `src/openhuman/channels/providers/email_channel.rs`. A rename there must
|
||||
// fail this assertion before the UI silently stops persisting the field.
|
||||
for key in [
|
||||
"imap_port",
|
||||
"smtp_port",
|
||||
"smtp_tls",
|
||||
"from_address",
|
||||
"imap_folder",
|
||||
"allowed_senders",
|
||||
] {
|
||||
let field = spec
|
||||
.fields
|
||||
.iter()
|
||||
.find(|f| f.key == key)
|
||||
.unwrap_or_else(|| panic!("email spec missing optional field: {key}"));
|
||||
assert!(
|
||||
!field.required,
|
||||
"email optional field {key} must not be required"
|
||||
);
|
||||
}
|
||||
let smtp_tls = spec.fields.iter().find(|f| f.key == "smtp_tls").unwrap();
|
||||
assert_eq!(smtp_tls.field_type, "boolean");
|
||||
// Default-on so the UI checkbox is pre-checked and a fresh connect keeps TLS
|
||||
// rather than silently reverting the default when the box is left untouched.
|
||||
assert_eq!(smtp_tls.default_bool, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_validate_credentials_rejects_missing_password() {
|
||||
let def = find_channel_definition("email").expect("email not found");
|
||||
let mut creds = serde_json::Map::new();
|
||||
creds.insert(
|
||||
"imap_host".into(),
|
||||
serde_json::Value::String("imap.x.com".into()),
|
||||
);
|
||||
creds.insert(
|
||||
"username".into(),
|
||||
serde_json::Value::String("u@x.com".into()),
|
||||
);
|
||||
creds.insert(
|
||||
"smtp_host".into(),
|
||||
serde_json::Value::String("smtp.x.com".into()),
|
||||
);
|
||||
// password intentionally omitted.
|
||||
let err = def
|
||||
.validate_credentials(ChannelAuthMode::ApiKey, &creds)
|
||||
.expect_err("must reject when password missing");
|
||||
assert!(err.contains("password"), "{err}");
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::openhuman::channels::email_channel::{EmailChannel, EmailConfig};
|
||||
use crate::openhuman::channels::providers::yuanbao::YuanbaoConfig;
|
||||
use crate::openhuman::channels::traits::Channel;
|
||||
use crate::openhuman::config::{Config, DiscordConfig, IMessageConfig, TelegramConfig};
|
||||
use crate::openhuman::credentials;
|
||||
use crate::openhuman::memory_store::chunks::store as memory_tree_store;
|
||||
@@ -134,6 +136,175 @@ pub(super) fn parse_optional_bool(value: Option<&Value>) -> Option<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a required non-empty string credential field.
|
||||
fn require_cred_str(creds: &serde_json::Map<String, Value>, key: &str) -> Result<String, String> {
|
||||
creds
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| format!("missing required field: {key}"))
|
||||
}
|
||||
|
||||
/// Read an optional non-empty string credential field.
|
||||
fn optional_cred_str(creds: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
|
||||
creds
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Parse a `u16` port from a string/number credential field, falling back to
|
||||
/// `default` when the field is absent or blank. Non-numeric values are a hard
|
||||
/// error so a typo surfaces at connect time rather than silently reverting.
|
||||
fn parse_port_field(
|
||||
creds: &serde_json::Map<String, Value>,
|
||||
key: &str,
|
||||
default: u16,
|
||||
) -> Result<u16, String> {
|
||||
// Port 0 is the OS "any" sentinel — never a valid mailbox port — so reject it
|
||||
// up front rather than letting it fail later with a generic connect error.
|
||||
let invalid = || format!("invalid {key}: must be a port number 1-65535");
|
||||
match creds.get(key) {
|
||||
Some(Value::Number(n)) => n
|
||||
.as_u64()
|
||||
.filter(|v| (1..=u64::from(u16::MAX)).contains(v))
|
||||
.map(|v| v as u16)
|
||||
.ok_or_else(invalid),
|
||||
Some(Value::String(s)) => {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(default)
|
||||
} else {
|
||||
match trimmed.parse::<u16>() {
|
||||
Ok(0) | Err(_) => Err(invalid()),
|
||||
Ok(port) => Ok(port),
|
||||
}
|
||||
}
|
||||
}
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
_ => Err(invalid()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the email `allowed_senders` allowlist from a comma/newline-separated
|
||||
/// credential field. Unlike [`parse_allowed_users`], this preserves a leading
|
||||
/// `@` (the domain-match syntax `@example.com` the email channel relies on) and
|
||||
/// does not force lowercase beyond what the channel already does at match time.
|
||||
/// An absent field defaults to `["*"]` (allow any) so a freshly-connected
|
||||
/// mailbox actually receives — the channel treats an *empty* list as deny-all.
|
||||
fn parse_email_senders(value: Option<&Value>) -> Vec<String> {
|
||||
let raw = match value {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Array(items)) => items
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
_ => return vec!["*".to_string()],
|
||||
};
|
||||
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for part in raw.split([',', '\n', '\r']) {
|
||||
let trimmed = part.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !out.iter().any(|e| e.eq_ignore_ascii_case(trimmed)) {
|
||||
out.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push("*".to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build an [`EmailConfig`] from the connect form's credential map, filling
|
||||
/// sensible defaults (ports 993/465, TLS on, folder INBOX, `from_address` =
|
||||
/// username, allowlist = `*`). Field keys map 1:1 to the `email` channel
|
||||
/// definition. Reuses `existing` only for the IDLE timeout so an advanced
|
||||
/// hand-set value survives a UI reconnect.
|
||||
fn build_email_config(
|
||||
creds: &serde_json::Map<String, Value>,
|
||||
existing: Option<&EmailConfig>,
|
||||
) -> Result<EmailConfig, String> {
|
||||
let username = require_cred_str(creds, "username")?;
|
||||
let from_address = optional_cred_str(creds, "from_address").unwrap_or_else(|| username.clone());
|
||||
Ok(EmailConfig {
|
||||
imap_host: require_cred_str(creds, "imap_host")?,
|
||||
imap_port: parse_port_field(creds, "imap_port", 993)?,
|
||||
imap_folder: optional_cred_str(creds, "imap_folder").unwrap_or_else(|| "INBOX".to_string()),
|
||||
smtp_host: require_cred_str(creds, "smtp_host")?,
|
||||
smtp_port: parse_port_field(creds, "smtp_port", 465)?,
|
||||
smtp_tls: parse_optional_bool(creds.get("smtp_tls")).unwrap_or(true),
|
||||
username,
|
||||
password: require_cred_str(creds, "password")?,
|
||||
from_address,
|
||||
idle_timeout_secs: existing.map_or(1740, |c| c.idle_timeout_secs),
|
||||
allowed_senders: parse_email_senders(creds.get("allowed_senders")),
|
||||
})
|
||||
}
|
||||
|
||||
/// Live-verify IMAP credentials by attempting a login. Runs before persistence
|
||||
/// so a wrong host/password fails fast in the UI instead of silently wedging
|
||||
/// the listener on the next core restart.
|
||||
async fn verify_email_credentials(cfg: &EmailConfig) -> Result<(), String> {
|
||||
// The probe dials IMAP + logs in over the network on the connect/test RPC
|
||||
// path, so bound it: a blackholed host or stalled TLS handshake must not
|
||||
// hang the UI. `health_check` has its own inner budget; this is a hard outer
|
||||
// cap that also distinguishes a timeout from an auth failure for the user.
|
||||
let probe = EmailChannel::new(cfg.clone());
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(20), probe.health_check()).await {
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => Err(
|
||||
"IMAP connection failed — check the host, port, email address, and app password"
|
||||
.to_string(),
|
||||
),
|
||||
Err(_) => Err(format!(
|
||||
"IMAP connection to {} timed out — check the host and port",
|
||||
cfg.imap_host
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist an already-built + verified [`EmailConfig`] into
|
||||
/// `channels_config.email` so the supervised IMAP/SMTP listener picks it up on
|
||||
/// the next restart. Kept separate from the verify step so persistence is unit
|
||||
/// testable without a live mailbox.
|
||||
///
|
||||
/// The `password` is deliberately **not** written to `config.toml` — the secret
|
||||
/// lives only in the encrypted credentials store (written on the generic connect
|
||||
/// path under `channel:email:api_key`) and is re-hydrated at startup by
|
||||
/// `resolve_email_password`. Mirrors the Yuanbao `app_secret` handling.
|
||||
pub(super) async fn persist_email_config(
|
||||
config: &Config,
|
||||
mut email_cfg: EmailConfig,
|
||||
) -> Result<(), String> {
|
||||
let allowed_senders_count = email_cfg.allowed_senders.len();
|
||||
let smtp_tls = email_cfg.smtp_tls;
|
||||
// Strip the secret before it ever touches disk.
|
||||
email_cfg.password = String::new();
|
||||
|
||||
let mut persisted = config.clone();
|
||||
persisted.channels_config.email = Some(email_cfg);
|
||||
persisted
|
||||
.save()
|
||||
.await
|
||||
.map_err(|e| format!("failed to persist email config.toml: {e}"))?;
|
||||
|
||||
tracing::info!(
|
||||
target: "openhuman::channels",
|
||||
allowed_senders_count,
|
||||
smtp_tls,
|
||||
"[email] connect_channel: wrote channels_config.email (password kept in credentials store); restart core for IMAP/SMTP listener"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_channel_memory(config: &Config, channel_id: &str) -> anyhow::Result<usize> {
|
||||
let exact = memory_tree_store::delete_chunks_by_source(config, SourceKind::Chat, channel_id)?;
|
||||
let prefixed = memory_tree_store::delete_chunks_by_source_prefix(
|
||||
@@ -211,6 +382,17 @@ pub async fn connect_channel(
|
||||
prebuilt_yuanbao_config = Some(effective);
|
||||
}
|
||||
|
||||
// Email (IMAP/SMTP): build the effective config and live-verify the IMAP
|
||||
// login BEFORE storing anything, so bad server settings surface in the UI
|
||||
// rather than persisting and wedging the listener on the next restart.
|
||||
// Reused below for persistence so verify and runtime can never diverge.
|
||||
let mut prebuilt_email_config: Option<EmailConfig> = None;
|
||||
if channel_id == "email" && auth_mode == ChannelAuthMode::ApiKey {
|
||||
let email_cfg = build_email_config(creds_map, config.channels_config.email.as_ref())?;
|
||||
verify_email_credentials(&email_cfg).await?;
|
||||
prebuilt_email_config = Some(email_cfg);
|
||||
}
|
||||
|
||||
// iMessage is local-only (no credentials): persist channels_config + return connected.
|
||||
if channel_id == "imessage" && auth_mode == ChannelAuthMode::ManagedDm {
|
||||
let allowed_contacts = parse_allowed_users(creds_map.get("allowed_contacts"));
|
||||
@@ -424,6 +606,13 @@ pub async fn connect_channel(
|
||||
target: "openhuman::channels",
|
||||
"[yuanbao] connect_channel: wrote channels_config.yuanbao (secret stored in credentials); restart core for WS listener"
|
||||
);
|
||||
} else if channel_id == "email" && auth_mode == ChannelAuthMode::ApiKey {
|
||||
// Reuse the config already built + IMAP-verified above so persistence
|
||||
// and verification can never diverge.
|
||||
let email_cfg = prebuilt_email_config.take().ok_or_else(|| {
|
||||
"internal error: email config not built before persistence".to_string()
|
||||
})?;
|
||||
persist_email_config(config, email_cfg).await?;
|
||||
}
|
||||
|
||||
Ok(RpcOutcome::single_log(
|
||||
@@ -507,6 +696,18 @@ pub async fn disconnect_channel(
|
||||
"[yuanbao] disconnect_channel: cleared channels_config.yuanbao"
|
||||
);
|
||||
}
|
||||
} else if channel_id == "email" && auth_mode == ChannelAuthMode::ApiKey {
|
||||
let mut persisted = config.clone();
|
||||
if persisted.channels_config.email.take().is_some() {
|
||||
persisted
|
||||
.save()
|
||||
.await
|
||||
.map_err(|e| format!("failed to clear email config.toml: {e}"))?;
|
||||
tracing::info!(
|
||||
target: "openhuman::channels",
|
||||
"[email] disconnect_channel: cleared channels_config.email"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let memory_chunks_deleted = if clear_memory {
|
||||
@@ -739,8 +940,22 @@ pub async fn test_channel(
|
||||
// Validate fields first.
|
||||
def.validate_credentials(auth_mode, creds_map)?;
|
||||
|
||||
// For now, field validation is the test. A future version can instantiate
|
||||
// the channel provider and call health_check().
|
||||
// Email supports a real connection test: build the effective config and
|
||||
// attempt an IMAP login without persisting anything.
|
||||
if channel_id == "email" && auth_mode == ChannelAuthMode::ApiKey {
|
||||
let email_cfg = build_email_config(creds_map, None)?;
|
||||
verify_email_credentials(&email_cfg).await?;
|
||||
return Ok(RpcOutcome::new(
|
||||
ChannelTestResult {
|
||||
success: true,
|
||||
message: "IMAP login succeeded.".to_string(),
|
||||
},
|
||||
vec![],
|
||||
));
|
||||
}
|
||||
|
||||
// For other channels, field validation is the test. A future version can
|
||||
// instantiate the channel provider and call health_check().
|
||||
Ok(RpcOutcome::new(
|
||||
ChannelTestResult {
|
||||
success: true,
|
||||
@@ -752,3 +967,121 @@ pub async fn test_channel(
|
||||
vec![],
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod email_config_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn creds(v: Value) -> serde_json::Map<String, Value> {
|
||||
v.as_object().cloned().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_email_config_applies_defaults() {
|
||||
let c = creds(json!({
|
||||
"imap_host": "imap.fastmail.com",
|
||||
"smtp_host": "smtp.fastmail.com",
|
||||
"username": "alice@example.com",
|
||||
"password": "app-pass",
|
||||
}));
|
||||
let cfg = build_email_config(&c, None).expect("should build");
|
||||
assert_eq!(cfg.imap_port, 993);
|
||||
assert_eq!(cfg.smtp_port, 465);
|
||||
assert!(cfg.smtp_tls);
|
||||
assert_eq!(cfg.imap_folder, "INBOX");
|
||||
// from_address defaults to the username when omitted.
|
||||
assert_eq!(cfg.from_address, "alice@example.com");
|
||||
// Absent allowlist defaults to allow-any so a fresh mailbox receives.
|
||||
assert_eq!(cfg.allowed_senders, vec!["*".to_string()]);
|
||||
assert_eq!(cfg.idle_timeout_secs, 1740);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_email_config_honors_explicit_values() {
|
||||
let c = creds(json!({
|
||||
"imap_host": "mail.self.host",
|
||||
"imap_port": "1993",
|
||||
"imap_folder": "Archive",
|
||||
"smtp_host": "mail.self.host",
|
||||
"smtp_port": "2465",
|
||||
"smtp_tls": "false",
|
||||
"username": "bob@self.host",
|
||||
"password": "secret",
|
||||
"from_address": "Bob <bob@self.host>",
|
||||
"allowed_senders": "@team.com, boss@corp.com , @team.com",
|
||||
}));
|
||||
let cfg = build_email_config(&c, None).expect("should build");
|
||||
assert_eq!(cfg.imap_port, 1993);
|
||||
assert_eq!(cfg.smtp_port, 2465);
|
||||
assert!(!cfg.smtp_tls);
|
||||
assert_eq!(cfg.imap_folder, "Archive");
|
||||
assert_eq!(cfg.from_address, "Bob <bob@self.host>");
|
||||
// '@'-domain syntax preserved; duplicate collapsed case-insensitively.
|
||||
assert_eq!(
|
||||
cfg.allowed_senders,
|
||||
vec!["@team.com".to_string(), "boss@corp.com".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_email_config_rejects_missing_required() {
|
||||
for missing in ["imap_host", "smtp_host", "username", "password"] {
|
||||
let mut obj = json!({
|
||||
"imap_host": "h",
|
||||
"smtp_host": "h",
|
||||
"username": "u",
|
||||
"password": "p",
|
||||
});
|
||||
obj.as_object_mut().unwrap().remove(missing);
|
||||
let err = build_email_config(&creds(obj), None)
|
||||
.expect_err("must reject missing required field");
|
||||
assert!(err.contains(missing), "error should name {missing}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_email_config_preserves_existing_idle_timeout() {
|
||||
let existing = EmailConfig {
|
||||
idle_timeout_secs: 600,
|
||||
..EmailConfig::default()
|
||||
};
|
||||
let c = creds(json!({
|
||||
"imap_host": "h", "smtp_host": "h", "username": "u", "password": "p",
|
||||
}));
|
||||
let cfg = build_email_config(&c, Some(&existing)).expect("should build");
|
||||
assert_eq!(cfg.idle_timeout_secs, 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_port_field_variants() {
|
||||
let c = creds(json!({
|
||||
"p_str": "8143", "p_blank": " ", "p_num": 143, "p_bad": "abc",
|
||||
"p_zero_str": "0", "p_zero_num": 0
|
||||
}));
|
||||
assert_eq!(parse_port_field(&c, "p_str", 993).unwrap(), 8143);
|
||||
assert_eq!(parse_port_field(&c, "p_blank", 993).unwrap(), 993);
|
||||
assert_eq!(parse_port_field(&c, "p_num", 993).unwrap(), 143);
|
||||
assert_eq!(parse_port_field(&c, "absent", 465).unwrap(), 465);
|
||||
assert!(parse_port_field(&c, "p_bad", 993).is_err());
|
||||
// Port 0 is the OS "any" sentinel, never valid for a mailbox.
|
||||
assert!(parse_port_field(&c, "p_zero_str", 993).is_err());
|
||||
assert!(parse_port_field(&c, "p_zero_num", 993).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_email_senders_defaults_and_dedup() {
|
||||
// Absent → allow any.
|
||||
assert_eq!(parse_email_senders(None), vec!["*".to_string()]);
|
||||
// Blank string → allow any (never accidental deny-all).
|
||||
assert_eq!(
|
||||
parse_email_senders(Some(&json!(" "))),
|
||||
vec!["*".to_string()]
|
||||
);
|
||||
// Array form joins, preserves '@', dedups.
|
||||
assert_eq!(
|
||||
parse_email_senders(Some(&json!(["@x.com", "a@y.com", "@X.COM"]))),
|
||||
vec!["@x.com".to_string(), "a@y.com".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1255,3 +1255,123 @@ async fn connect_yuanbao_persists_env_override() {
|
||||
Some("canary")
|
||||
);
|
||||
}
|
||||
|
||||
// ── email (IMAP/SMTP) channel — #4280 ──────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_email_config_writes_channels_config_email() {
|
||||
let (_tmp, config) = isolated_test_config();
|
||||
let cfg = EmailConfig {
|
||||
imap_host: "imap.fastmail.com".into(),
|
||||
smtp_host: "smtp.fastmail.com".into(),
|
||||
username: "me@fastmail.com".into(),
|
||||
password: "app-pass".into(),
|
||||
from_address: "me@fastmail.com".into(),
|
||||
allowed_senders: vec!["*".into()],
|
||||
..EmailConfig::default()
|
||||
};
|
||||
|
||||
super::connect::persist_email_config(&config, cfg)
|
||||
.await
|
||||
.expect("persist should succeed");
|
||||
|
||||
let raw = tokio::fs::read_to_string(&config.config_path)
|
||||
.await
|
||||
.expect("saved config should exist");
|
||||
let parsed: toml::Value = toml::from_str(&raw).expect("saved config should parse");
|
||||
let email = parsed
|
||||
.get("channels_config")
|
||||
.and_then(|v| v.get("email"))
|
||||
.and_then(toml::Value::as_table)
|
||||
.expect("channels_config.email persisted");
|
||||
assert_eq!(
|
||||
email.get("imap_host").and_then(toml::Value::as_str),
|
||||
Some("imap.fastmail.com")
|
||||
);
|
||||
assert_eq!(
|
||||
email.get("smtp_host").and_then(toml::Value::as_str),
|
||||
Some("smtp.fastmail.com")
|
||||
);
|
||||
// The secret must never hit disk — it lives only in the credentials store.
|
||||
assert_eq!(
|
||||
email.get("password").and_then(toml::Value::as_str),
|
||||
Some(""),
|
||||
"password must not be persisted to config.toml"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_email_clears_channels_config() {
|
||||
let (_tmp, mut config) = isolated_test_config();
|
||||
config.channels_config.email = Some(EmailConfig {
|
||||
imap_host: "imap.x".into(),
|
||||
smtp_host: "smtp.x".into(),
|
||||
username: "u@x".into(),
|
||||
password: "p".into(),
|
||||
from_address: "u@x".into(),
|
||||
allowed_senders: vec!["*".into()],
|
||||
..EmailConfig::default()
|
||||
});
|
||||
config
|
||||
.save()
|
||||
.await
|
||||
.expect("preloaded config should be persisted");
|
||||
|
||||
disconnect_channel(&config, "email", ChannelAuthMode::ApiKey, false)
|
||||
.await
|
||||
.expect("email disconnect should succeed");
|
||||
|
||||
let raw = tokio::fs::read_to_string(&config.config_path)
|
||||
.await
|
||||
.expect("saved config should exist");
|
||||
let parsed: toml::Value = toml::from_str(&raw).expect("saved config should parse");
|
||||
assert!(
|
||||
parsed
|
||||
.get("channels_config")
|
||||
.and_then(|v| v.get("email"))
|
||||
.is_none(),
|
||||
"channels_config.email should be removed after disconnect"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_email_rejects_invalid_port_before_network() {
|
||||
// All required fields present so validation passes; a non-numeric port makes
|
||||
// build_email_config fail in the pre-verify step, before any IMAP dial.
|
||||
let config = Config::default();
|
||||
let err = connect_channel(
|
||||
&config,
|
||||
"email",
|
||||
ChannelAuthMode::ApiKey,
|
||||
serde_json::json!({
|
||||
"imap_host": "imap.x.com",
|
||||
"imap_port": "not-a-port",
|
||||
"username": "u@x.com",
|
||||
"password": "secret",
|
||||
"smtp_host": "smtp.x.com",
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect_err("invalid port must be rejected");
|
||||
assert!(err.contains("imap_port"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_channel_email_rejects_invalid_port_before_network() {
|
||||
let config = Config::default();
|
||||
let err = test_channel(
|
||||
&config,
|
||||
"email",
|
||||
ChannelAuthMode::ApiKey,
|
||||
serde_json::json!({
|
||||
"imap_host": "imap.x.com",
|
||||
"username": "u@x.com",
|
||||
"password": "secret",
|
||||
"smtp_host": "smtp.x.com",
|
||||
"smtp_port": "nope",
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect_err("invalid smtp port must be rejected");
|
||||
assert!(err.contains("smtp_port"), "{err}");
|
||||
}
|
||||
|
||||
@@ -589,7 +589,8 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
|
||||
}
|
||||
|
||||
if let Some(ref email_cfg) = config.channels_config.email {
|
||||
channels.push(Arc::new(EmailChannel::new(email_cfg.clone())));
|
||||
let hydrated = resolve_email_password(email_cfg.clone(), &config);
|
||||
channels.push(Arc::new(EmailChannel::new(hydrated)));
|
||||
}
|
||||
|
||||
if let Some(ref irc) = config.channels_config.irc {
|
||||
@@ -894,6 +895,48 @@ fn resolve_yuanbao_app_secret(
|
||||
yb_cfg
|
||||
}
|
||||
|
||||
/// Best-effort fill of `email_cfg.password` from the encrypted credentials store
|
||||
/// when TOML doesn't already carry one.
|
||||
///
|
||||
/// The IMAP/SMTP `password` is intentionally not persisted in `config.toml` (see
|
||||
/// `persist_email_config` in `controllers/ops/connect.rs`); it lives only in the
|
||||
/// credentials store under `channel:email:api_key`. Existing TOML values still
|
||||
/// win so manually-installed deployments keep working. The stored secret is only
|
||||
/// copied when the stored profile's `username` matches, so editing `username` in
|
||||
/// `config.toml` can't silently pair a fresh account with a stale password.
|
||||
fn resolve_email_password(
|
||||
mut email_cfg: crate::openhuman::channels::email_channel::EmailConfig,
|
||||
config: &Config,
|
||||
) -> crate::openhuman::channels::email_channel::EmailConfig {
|
||||
if !email_cfg.password.is_empty() {
|
||||
return email_cfg;
|
||||
}
|
||||
let auth = crate::openhuman::credentials::AuthService::from_config(config);
|
||||
match auth.get_profile("channel:email:api_key", None) {
|
||||
Ok(Some(profile)) => {
|
||||
let stored_username = profile.metadata.get("username").map(String::as_str);
|
||||
if stored_username != Some(email_cfg.username.as_str()) {
|
||||
tracing::warn!(
|
||||
"[channels] email stored credentials are for a different username (toml={:?}, store={:?}); reconnect the channel to refresh the password",
|
||||
email_cfg.username,
|
||||
stored_username,
|
||||
);
|
||||
} else if let Some(password) = profile.metadata.get("password") {
|
||||
email_cfg.password = password.clone();
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
"[channels] email credentials missing — connect the channel again from the UI"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[channels] failed to load email credentials: {e}");
|
||||
}
|
||||
}
|
||||
email_cfg
|
||||
}
|
||||
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
pub mod test_support {
|
||||
use super::*;
|
||||
@@ -1005,3 +1048,75 @@ mod yuanbao_secret_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod email_secret_tests {
|
||||
use super::*;
|
||||
use crate::openhuman::channels::email_channel::EmailConfig;
|
||||
use crate::openhuman::credentials::AuthService;
|
||||
use std::collections::HashMap;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn isolated_config() -> (tempfile::TempDir, Config) {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let mut config = Config::default();
|
||||
config.workspace_dir = tmp.path().join("workspace");
|
||||
config.config_path = tmp.path().join("config.toml");
|
||||
std::fs::create_dir_all(&config.workspace_dir).expect("workspace dir");
|
||||
(tmp, config)
|
||||
}
|
||||
|
||||
fn store_email_creds(config: &Config, username: &str, password: &str) {
|
||||
let auth = AuthService::from_config(config);
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert("username".to_string(), username.to_string());
|
||||
metadata.insert("password".to_string(), password.to_string());
|
||||
auth.store_provider_token("channel:email:api_key", "default", "", metadata, true)
|
||||
.expect("store credentials");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_password_from_credentials_when_toml_empty() {
|
||||
let (_tmp, config) = isolated_config();
|
||||
store_email_creds(&config, "me@example.com", "from-credentials");
|
||||
|
||||
let cfg = EmailConfig {
|
||||
username: "me@example.com".into(),
|
||||
password: String::new(),
|
||||
..EmailConfig::default()
|
||||
};
|
||||
let resolved = resolve_email_password(cfg, &config);
|
||||
assert_eq!(resolved.password, "from-credentials");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_existing_toml_password_without_consulting_store() {
|
||||
let (_tmp, config) = isolated_config();
|
||||
let cfg = EmailConfig {
|
||||
username: "me@example.com".into(),
|
||||
password: "from-toml".into(),
|
||||
..EmailConfig::default()
|
||||
};
|
||||
let resolved = resolve_email_password(cfg, &config);
|
||||
assert_eq!(resolved.password, "from-toml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_hydration_when_stored_profile_has_different_username() {
|
||||
// User changed `username` in config.toml; the stored profile is for the
|
||||
// old account. The resolver must not graft the old password onto it.
|
||||
let (_tmp, config) = isolated_config();
|
||||
store_email_creds(&config, "old@example.com", "old-password-do-not-use");
|
||||
|
||||
let cfg = EmailConfig {
|
||||
username: "new@example.com".into(),
|
||||
password: String::new(),
|
||||
..EmailConfig::default()
|
||||
};
|
||||
let resolved = resolve_email_password(cfg, &config);
|
||||
assert_eq!(
|
||||
resolved.password, "",
|
||||
"stale profile for old username must not hydrate the new account",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user