diff --git a/CHANGELOG.md b/CHANGELOG.md index 30af8c55..521abe7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,32 @@ This project adheres to [Semantic Versioning](https://semver.org/). `~/.vibe-trading/agent.json` atomically at mode 0600; a YAML config is shown read-only. Reloads of one channel are serialized in `ChannelManager`, and `stop_all` cancels a reload's start that is still connecting. +- **Email and WebSocket join the guided Web UI channel setup** (a follow-up + to #1519). Both channels get hand-written field metadata — localized + labels, masked secrets — plus connection tests and per-channel hot apply. + Email's test probes the real IMAP login, mailbox select and SMTP login + without ever sending a message; WebSocket is a server channel with no + remote credentials, so its test validates the TLS cert/key material and + binds every resolved address locally — an address held by the + already-running server reads as the expected state, not a failure. Saving + a WebSocket config hot-swaps the server: connected clients (including the + Web UI chat) briefly disconnect and reconnect. A hand-written field hint's + secret flag is now authoritative for the keys it covers — the regex + fail-safe still masks every unhinted key — so token-shaped non-secrets + such as `websocket_requires_token` stay visible and editable instead of + being masked, and a form save can no longer silently flip the stored token + requirement. The web config routes now build the WebSocket adapter with + the gateway services it requires; previously every WebSocket save or test + failed validation. Email's polling loop also skips stale-config + delete/move post-actions once a stop or hot swap begins, while the + already-fetched batch is still delivered. Both the probe and the polling + loop now send an RFC 2971 IMAP `ID` after login: NetEase mailboxes + (163/126/yeah.net) accept the login but reject the first `SELECT` with + `Unsafe Login` until the client identifies itself, so without this the + Email channel could not read the most common Chinese mailboxes. The + identification is static and carries no user data or secrets, and servers + that ignore `ID` are unaffected; verified end-to-end against a live + 163.com account. - **Bahasa Indonesia** UI locale and `README_id.md` (#1482). The README count tests cover it, and `MANIFEST.in` now ships `README_es.md` and `README_id.md`. @@ -219,7 +245,28 @@ This project adheres to [Semantic Versioning](https://semver.org/). excluded the other entry as "self" and wrote an ambiguous target. Both key on the path relative to the memory dir now; a sidecar written earlier, with a bare filename or an absolute path, is still read. - +- **The Email channel verifies TLS certificates by default** (#1544). The + implicit-SSL paths (`IMAP4_SSL` / `SMTP_SSL`) in both the connection probe + and the polling/send adapter used Python's default unverified context + (`CERT_NONE`, no hostname check), so the mailbox password travelled to a + server whose certificate was never checked — an active network attacker + between the operator and their mail provider could harvest it. All four call + sites now build their context through a shared `email_tls_context()` that + verifies the certificate and hostname against the system CA bundle, matching + the STARTTLS path, which already did. A new `verify_tls` field (default + `true`, surfaced in the guided Email setup in all nine locales) is the + documented opt-out for self-signed or internal-CA servers. **Upgrade note:** + a mail server with such a certificate connected before this change and now + fails the TLS handshake until `verify_tls` is set to `false` for it. +- **Settings-write routes reject cross-site browser requests** (#1544). + `require_settings_write_auth` did not apply the cross-site guard that its + siblings `require_auth` and `require_event_stream_auth` enforce on unsafe + methods, so a malicious web page could attempt a CSRF write against any + settings route — including the new `POST /channels/email/test`, which merges + stored credentials with a caller-supplied patch and could be steered to send + the stored mailbox password to an attacker-chosen host. The guard now runs + first on every settings-write route; same-origin Web UI calls and + non-browser clients (CLI/curl, no `Origin`) are unaffected. - **Read-only results lost to context compaction are restored, not refetched** (#1488). A successful read-only call whose payload compaction removed is replayed from the run's own cache, at most six times a run; past the cap it diff --git a/README.md b/README.md index d047c50c..849123ca 100644 --- a/README.md +++ b/README.md @@ -1148,7 +1148,7 @@ Commands are case-insensitive and must be sent as the entire message (e.g. `hell > **`/pairing` is operator-gated.** In-chat pairing-control commands are rejected unless the sender is listed as an operator — set `channels.operators` (cross-channel authority) or a channel section's own `operators` list in your channels config. With no operators configured, in-chat `/pairing` is refused (fail-closed) and pairing is managed only through the authenticated CLI (`vibe-trading channels pairing …`) and the auth-gated REST endpoint. This prevents any allow-listed group member from taking over pairing across channels. -**Configure from the Web UI**: the **IM Channels** panel on the Settings page configures channels without hand-editing files. Expand a channel to open its config panel: fields render from backend metadata, and secret values are never returned to the browser, only masked (`****` plus the last 4 characters). DingTalk is the first fully-guided channel: create an app at [open-dev.dingtalk.com](https://open-dev.dingtalk.com/), add the bot capability, enable Stream Mode (no public callback URL needed), copy the AppKey into Client ID and the AppSecret into Client Secret, then publish the app. QQ has joined it as a fully-guided channel: register a bot on the QQ Open Platform ([q.qq.com](https://q.qq.com/)), copy the AppID and AppSecret into the form, verify with **Test connection** before saving, then enable; no public callback URL is needed (WebSocket via the official botpy SDK). **Test connection** probes the values in the form before anything is saved and answers with an honest result code (`ok`, `invalid_credentials`, `network`, or `unsupported`). **Enable** applies immediately: a running channel runtime hot-swaps that one adapter with no process restart, and enabling auto-verifies credentials unless you explicitly choose **Enable anyway** after a failed check. Disabling a channel keeps its stored credentials, so re-enabling needs no re-entry. Only the built-in adapters are listed here; entry-points plugin channels remain file-configured. +**Configure from the Web UI**: the **IM Channels** panel on the Settings page configures channels without hand-editing files. Expand a channel to open its config panel: fields render from backend metadata, and secret values are never returned to the browser, only masked (`****` plus the last 4 characters). DingTalk is the first fully-guided channel: create an app at [open-dev.dingtalk.com](https://open-dev.dingtalk.com/), add the bot capability, enable Stream Mode (no public callback URL needed), copy the AppKey into Client ID and the AppSecret into Client Secret, then publish the app. QQ has joined it as a fully-guided channel: register a bot on the QQ Open Platform ([q.qq.com](https://q.qq.com/)), copy the AppID and AppSecret into the form, verify with **Test connection** before saving, then enable; no public callback URL is needed (WebSocket via the official botpy SDK). Email and WebSocket have joined as fully-guided channels: Email's **Test connection** probes the IMAP login, the mailbox folder and the SMTP login against your provider without sending anything, while WebSocket — a local server rather than a remote service — validates the TLS certificate/key material and the address availability on this machine; an address held by the already-running server reads as the expected state, not a failure. Saving a WebSocket config hot-swaps the server, so connected clients (including the Web UI chat) briefly disconnect and reconnect. **Test connection** probes the values in the form before anything is saved and answers with an honest result code (`ok`, `invalid_credentials`, `network`, or `unsupported`). **Enable** applies immediately: a running channel runtime hot-swaps that one adapter with no process restart, and enabling auto-verifies credentials unless you explicitly choose **Enable anyway** after a failed check. Disabling a channel keeps its stored credentials, so re-enabling needs no re-entry. Only the built-in adapters are listed here; entry-points plugin channels remain file-configured. Saves write the `channels.` section of `~/.vibe-trading/agent.json` atomically. YAML config files are read-only for the web UI (JSON is required to edit from the browser), and the panel says so instead of failing. Channels without a dedicated guide render a generic form derived from their defaults. diff --git a/README_ar.md b/README_ar.md index 31641494..2927da5b 100644 --- a/README_ar.md +++ b/README_ar.md @@ -1032,7 +1032,7 @@ vibe-trading channels pairing --channel telegram list الأوامر لا تحس بحالة الأحرف ويجب إرسالها كرسالة كاملة (مثلاً `hello /new` تُعامل كرسالة عادية وليس كأمر إعادة تعيين). -**التكوين من واجهة الويب**: تتيح لوحة **IM Channels** في صفحة Settings ضبط القنوات دون تحرير الملفات يدويًا. افتح قناةً لتظهر لوحة تكوينها: تُرسم الحقول من بيانات وصفية يوفّرها الخادم، ولا تُرسل قيم الأسرار إلى المتصفح أبدًا، بل تظهر مقنّعة (`****` مع آخر 4 محارف). DingTalk هي أول قناة يغطيها دليل إعداد كامل: أنشئ تطبيقًا في [open-dev.dingtalk.com](https://open-dev.dingtalk.com/)، ثم أضف إمكانية الروبوت وفعّل وضع Stream Mode (لا حاجة إلى عنوان callback عام)، وانسخ AppKey إلى Client ID وAppSecret إلى Client Secret، ثم انشر التطبيق. انضمّت QQ إليها كقناة يغطيها دليل إعداد كامل: سجّل روبوتًا في منصة QQ Open Platform ([q.qq.com](https://q.qq.com/))، وانسخ AppID وAppSecret إلى النموذج، وتحقّق عبر **Test connection** قبل الحفظ ثم فعّل؛ لا حاجة إلى عنوان callback عام (WebSocket عبر حزمة botpy الرسمية). يختبر زر **Test connection** قيم النموذج الحالية قبل حفظ أي شيء، ويعيد رمز نتيجة صادقًا (`ok` أو `invalid_credentials` أو `network` أو `unsupported`). يُطبَّق خيار **Enable** فورًا: بيئة تشغيل القنوات القائمة تبدّل هذا المحوّل وحده دون إعادة تشغيل العملية، والتحقق من بيانات الاعتماد يتم تلقائيًا عند التفعيل إلا إذا اخترت صراحةً **Enable anyway** بعد فشل التحقق. الإيقاف يبقي بيانات الاعتماد المخزنة، لذا لا حاجة لإعادة إدخالها عند التفعيل مجددًا. تُعرض هنا المحوّلات المدمجة فقط؛ وتبقى قنوات الإضافات المضافة عبر نقاط الدخول (entry points) قابلة للتهيئة من الملفات فقط. +**التكوين من واجهة الويب**: تتيح لوحة **IM Channels** في صفحة Settings ضبط القنوات دون تحرير الملفات يدويًا. افتح قناةً لتظهر لوحة تكوينها: تُرسم الحقول من بيانات وصفية يوفّرها الخادم، ولا تُرسل قيم الأسرار إلى المتصفح أبدًا، بل تظهر مقنّعة (`****` مع آخر 4 محارف). DingTalk هي أول قناة يغطيها دليل إعداد كامل: أنشئ تطبيقًا في [open-dev.dingtalk.com](https://open-dev.dingtalk.com/)، ثم أضف إمكانية الروبوت وفعّل وضع Stream Mode (لا حاجة إلى عنوان callback عام)، وانسخ AppKey إلى Client ID وAppSecret إلى Client Secret، ثم انشر التطبيق. انضمّت QQ إليها كقناة يغطيها دليل إعداد كامل: سجّل روبوتًا في منصة QQ Open Platform ([q.qq.com](https://q.qq.com/))، وانسخ AppID وAppSecret إلى النموذج، وتحقّق عبر **Test connection** قبل الحفظ ثم فعّل؛ لا حاجة إلى عنوان callback عام (WebSocket عبر حزمة botpy الرسمية). انضمّت Email و WebSocket أيضًا كقناتين يغطيهما دليل إعداد كامل: يختبر **Test connection** لدى Email تسجيل الدخول عبر IMAP ومجلد صندوق البريد وتسجيل الدخول عبر SMTP لدى مزوّد خدمتك دون إرسال أي شيء، بينما يتحقق WebSocket — وهو خادم محلي وليس خدمة عن بُعد — من مواد شهادة/مفتاح TLS ومن توفّر العنوان على هذا الجهاز؛ والعنوان المحتلّ من الخادم قيد التشغيل أصلًا يُقرأ كحالة متوقعة وليس كفشل. يؤدي حفظ إعداد WebSocket إلى تبديل الخادم على الساخن، لذا ينفصل العملاء المتصلون (بما في ذلك محادثة واجهة الويب) لفترة وجيزة ثم يعيدون الاتصال. يختبر زر **Test connection** قيم النموذج الحالية قبل حفظ أي شيء، ويعيد رمز نتيجة صادقًا (`ok` أو `invalid_credentials` أو `network` أو `unsupported`). يُطبَّق خيار **Enable** فورًا: بيئة تشغيل القنوات القائمة تبدّل هذا المحوّل وحده دون إعادة تشغيل العملية، والتحقق من بيانات الاعتماد يتم تلقائيًا عند التفعيل إلا إذا اخترت صراحةً **Enable anyway** بعد فشل التحقق. الإيقاف يبقي بيانات الاعتماد المخزنة، لذا لا حاجة لإعادة إدخالها عند التفعيل مجددًا. تُعرض هنا المحوّلات المدمجة فقط؛ وتبقى قنوات الإضافات المضافة عبر نقاط الدخول (entry points) قابلة للتهيئة من الملفات فقط. عند الحفظ يُحدَّث قسم `channels.` في `~/.vibe-trading/agent.json` بشكل ذرّي. ملفات إعداد YAML للقراءة فقط في واجهة الويب (التحرير من المتصفح يتطلب JSON)، وتعرض اللوحة هذه الرسالة بدلاً من الفشل. القنوات التي لا تملك دليلًا مخصصًا تعرض نماذج عامة مشتقة من إعداداتها الافتراضية. diff --git a/README_es.md b/README_es.md index b62a555b..b5265186 100644 --- a/README_es.md +++ b/README_es.md @@ -1087,7 +1087,7 @@ Los comandos no distinguen mayúsculas/minúsculas y deben enviarse como el mens > **`/pairing` está restringido a operadores.** Los comandos de control de emparejamiento dentro del chat se rechazan a menos que el remitente figure como operador — configura `channels.operators` (autoridad entre canales) o la propia lista `operators` de la sección de un canal en tu configuración de canales. Sin operadores configurados, `/pairing` dentro del chat se rechaza (fail-closed) y el emparejamiento solo se gestiona a través de la CLI autenticada (`vibe-trading channels pairing …`) y el endpoint REST protegido por autenticación. Esto evita que cualquier miembro de un grupo en la lista de permitidos pueda tomar control del emparejamiento entre canales. -**Configuración desde la Web UI**: el panel **IM Channels** de la página Settings ahora configura canales sin editar archivos a mano. Despliega un canal para abrir su panel de configuración: los campos se renderizan desde metadatos del backend, y los valores secretos nunca se devuelven al navegador, solo enmascarados (`****` más los últimos 4 caracteres). DingTalk es el primer canal con guía completa: crea una aplicación en [open-dev.dingtalk.com](https://open-dev.dingtalk.com/), agrega la capacidad de bot, habilita Stream Mode (no hace falta una URL de callback pública), copia el AppKey a Client ID y el AppSecret a Client Secret, y publica la aplicación. QQ se suma como canal con guía completa: registra un bot en la QQ Open Platform ([q.qq.com](https://q.qq.com/)), copia el AppID y el AppSecret al formulario, verifica con **Test connection** antes de guardar y habilita; no hace falta una URL de callback pública (WebSocket mediante el SDK oficial botpy). **Test connection** prueba los valores del formulario antes de guardar cualquier cosa y responde con un código honesto (`ok`, `invalid_credentials`, `network` o `unsupported`). **Enable** se aplica de inmediato: un runtime de canales en ejecución intercambia en caliente solo ese adaptador sin reiniciar el proceso, y al habilitar se verifican las credenciales automáticamente salvo que elijas explícitamente **Enable anyway** tras una verificación fallida. Deshabilitar conserva las credenciales almacenadas, así que reactivar no exige volver a escribirlas. Aquí solo se enumeran los adaptadores integrados; los canales de plugins vía entry points siguen configurándose por archivo. +**Configuración desde la Web UI**: el panel **IM Channels** de la página Settings ahora configura canales sin editar archivos a mano. Despliega un canal para abrir su panel de configuración: los campos se renderizan desde metadatos del backend, y los valores secretos nunca se devuelven al navegador, solo enmascarados (`****` más los últimos 4 caracteres). DingTalk es el primer canal con guía completa: crea una aplicación en [open-dev.dingtalk.com](https://open-dev.dingtalk.com/), agrega la capacidad de bot, habilita Stream Mode (no hace falta una URL de callback pública), copia el AppKey a Client ID y el AppSecret a Client Secret, y publica la aplicación. QQ se suma como canal con guía completa: registra un bot en la QQ Open Platform ([q.qq.com](https://q.qq.com/)), copia el AppID y el AppSecret al formulario, verifica con **Test connection** antes de guardar y habilita; no hace falta una URL de callback pública (WebSocket mediante el SDK oficial botpy). Email y WebSocket se suman también como canales con guía completa: **Test connection** de Email prueba el inicio de sesión IMAP, la carpeta del buzón y el inicio de sesión SMTP contra tu proveedor sin enviar nada, mientras que WebSocket —un servidor local en lugar de un servicio remoto— valida el material de certificado/clave TLS y la disponibilidad de la dirección en esta máquina; una dirección ocupada por el servidor ya en ejecución se lee como el estado esperado, no como un fallo. Guardar una configuración de WebSocket intercambia el servidor en caliente, así que los clientes conectados (incluido el chat de la Web UI) se desconectan y reconectan brevemente. **Test connection** prueba los valores del formulario antes de guardar cualquier cosa y responde con un código honesto (`ok`, `invalid_credentials`, `network` o `unsupported`). **Enable** se aplica de inmediato: un runtime de canales en ejecución intercambia en caliente solo ese adaptador sin reiniciar el proceso, y al habilitar se verifican las credenciales automáticamente salvo que elijas explícitamente **Enable anyway** tras una verificación fallida. Deshabilitar conserva las credenciales almacenadas, así que reactivar no exige volver a escribirlas. Aquí solo se enumeran los adaptadores integrados; los canales de plugins vía entry points siguen configurándose por archivo. Guardar actualiza de forma atómica la sección `channels.` de `~/.vibe-trading/agent.json`. Los archivos YAML son de solo lectura para la web UI (se requiere JSON para editar desde el navegador), y el panel lo indica en lugar de fallar. Los canales sin guía dedicada muestran un formulario genérico derivado de su configuración por defecto. diff --git a/README_id.md b/README_id.md index 73c2e83c..a3777fb7 100644 --- a/README_id.md +++ b/README_id.md @@ -1276,7 +1276,7 @@ Perintah tidak peka huruf besar/kecil dan harus dikirim sebagai seluruh isi pesa > **`/pairing` dibatasi untuk operator.** Perintah kontrol pairing di chat ditolak kecuali sender terdaftar sebagai operator — atur `channels.operators` (otoritas lintas-channel) atau daftar `operators` milik section channel di config Anda. Jika tidak ada operator yang dikonfigurasi, `/pairing` di chat ditolak secara fail-closed dan pairing hanya dikelola melalui CLI terautentikasi (`vibe-trading channels pairing …`) dan endpoint REST yang dilindungi auth. Ini mencegah anggota grup yang hanya di-allowlist mengambil alih pairing antar-channel. -**Konfigurasi dari Web UI**: panel **IM Channels** di halaman Settings mengonfigurasi channel tanpa mengedit file secara manual. Buka sebuah channel untuk melihat panel konfigurasinya: field dirender dari metadata backend, dan nilai secret tidak pernah dikirim kembali ke browser, hanya versi tersamarkan (`****` ditambah 4 karakter terakhir). DingTalk adalah channel pertama dengan panduan lengkap: buat app di [open-dev.dingtalk.com](https://open-dev.dingtalk.com/), tambahkan kemampuan bot, aktifkan Stream Mode (tidak perlu URL callback publik), salin AppKey ke Client ID dan AppSecret ke Client Secret, lalu publikasikan app. QQ menyusul sebagai channel dengan panduan lengkap: daftarkan bot di QQ Open Platform ([q.qq.com](https://q.qq.com/)), salin AppID dan AppSecret ke formulir, verifikasi dengan **Test connection** sebelum menyimpan, lalu aktifkan; tidak perlu URL callback publik (WebSocket melalui SDK resmi botpy). **Test connection** menguji nilai di formulir sebelum apa pun disimpan dan menjawab dengan kode hasil yang jujur (`ok`, `invalid_credentials`, `network`, atau `unsupported`). **Enable** langsung berlaku: runtime channel yang sedang berjalan mengganti adapter tersebut saja tanpa restart proses, dan pengaktifan memverifikasi kredensial secara otomatis kecuali Anda memilih **Enable anyway** setelah pemeriksaan gagal. Menonaktifkan channel tetap menyimpan kredensialnya, sehingga mengaktifkannya kembali tidak perlu mengisi ulang. Hanya adapter bawaan yang tercantum di sini; channel plugin dari entry-points tetap dikonfigurasi lewat file. +**Konfigurasi dari Web UI**: panel **IM Channels** di halaman Settings mengonfigurasi channel tanpa mengedit file secara manual. Buka sebuah channel untuk melihat panel konfigurasinya: field dirender dari metadata backend, dan nilai secret tidak pernah dikirim kembali ke browser, hanya versi tersamarkan (`****` ditambah 4 karakter terakhir). DingTalk adalah channel pertama dengan panduan lengkap: buat app di [open-dev.dingtalk.com](https://open-dev.dingtalk.com/), tambahkan kemampuan bot, aktifkan Stream Mode (tidak perlu URL callback publik), salin AppKey ke Client ID dan AppSecret ke Client Secret, lalu publikasikan app. QQ menyusul sebagai channel dengan panduan lengkap: daftarkan bot di QQ Open Platform ([q.qq.com](https://q.qq.com/)), salin AppID dan AppSecret ke formulir, verifikasi dengan **Test connection** sebelum menyimpan, lalu aktifkan; tidak perlu URL callback publik (WebSocket melalui SDK resmi botpy). Email dan WebSocket telah bergabung sebagai channel dengan panduan lengkap: **Test connection** milik Email menguji login IMAP, folder mailbox, dan login SMTP ke penyedia Anda tanpa mengirim apa pun, sementara WebSocket — sebuah server lokal, bukan layanan jarak jauh — memverifikasi materi sertifikat/kunci TLS dan ketersediaan alamat di mesin ini; alamat yang dipegang oleh server yang sedang berjalan terbaca sebagai kondisi yang diharapkan, bukan kegagalan. Menyimpan konfigurasi WebSocket mengganti server secara hot-swap, sehingga klien yang terhubung (termasuk chat Web UI) terputus sebentar lalu menyambung kembali. **Test connection** menguji nilai di formulir sebelum apa pun disimpan dan menjawab dengan kode hasil yang jujur (`ok`, `invalid_credentials`, `network`, atau `unsupported`). **Enable** langsung berlaku: runtime channel yang sedang berjalan mengganti adapter tersebut saja tanpa restart proses, dan pengaktifan memverifikasi kredensial secara otomatis kecuali Anda memilih **Enable anyway** setelah pemeriksaan gagal. Menonaktifkan channel tetap menyimpan kredensialnya, sehingga mengaktifkannya kembali tidak perlu mengisi ulang. Hanya adapter bawaan yang tercantum di sini; channel plugin dari entry-points tetap dikonfigurasi lewat file. Penyimpanan menulis section `channels.` di `~/.vibe-trading/agent.json` secara atomik. File konfigurasi YAML bersifat read-only bagi Web UI (edit dari browser memerlukan JSON), dan panel menyatakannya alih-alih gagal. Channel tanpa panduan khusus menampilkan formulir generik yang diturunkan dari default-nya. diff --git a/README_ja.md b/README_ja.md index c29833da..0cb618ff 100644 --- a/README_ja.md +++ b/README_ja.md @@ -1034,7 +1034,7 @@ Built-in adapters は `websocket`、`telegram`、`slack`、`discord`、`matrix` コマンドは大文字小文字を区別せず、メッセージ全体として送信する必要があります(例:`hello /new` はリセットではなく通常メッセージとして処理されます)。 -**Web UI から設定**: Settings ページの **IM Channels** パネルでは、ファイルを手編集せずにチャンネルを設定できます。チャンネルを展開すると設定パネルが開きます。フィールドはバックエンドのメタデータから描画され、secret の値はブラウザに返されず、マスク表示(`****` +末尾 4 文字)のみです。DingTalk が最初の完全なガイド付きチャンネルです:[open-dev.dingtalk.com](https://open-dev.dingtalk.com/) でアプリを作成し、ボット機能を追加して Stream Mode を有効化(公開コールバック URL 不要)、AppKey を Client ID、AppSecret を Client Secret にコピーしてアプリを公開します。QQ も完全ガイド付きチャンネルになりました:QQ公式プラットフォーム([q.qq.com](https://q.qq.com/))でボットを登録し、AppID と AppSecret をフォームにコピーし、保存前に **Test connection** で検証してから有効化します(公開コールバック URL 不要、公式 botpy SDK による WebSocket 接続)。**Test connection** は保存前にフォームの入力値だけで接続を試み、正直な結果コード(`ok` / `invalid_credentials` / `network` / `unsupported`)を返します。**Enable** は即適用:稼働中の channel runtime はプロセス再起動なしに該当アダプターのみをホットスワップし、有効化時に認証情報を自動検証します(検証失敗後に明示的に **Enable anyway** を選んだ場合のみスキップ)。無効化しても保存済み認証情報は残るため、再有効化時に再入力は不要です。ここには組み込みアダプターのみが表示されます。entry points 経由のプラグインチャンネルは引き続きファイルで設定します。 +**Web UI から設定**: Settings ページの **IM Channels** パネルでは、ファイルを手編集せずにチャンネルを設定できます。チャンネルを展開すると設定パネルが開きます。フィールドはバックエンドのメタデータから描画され、secret の値はブラウザに返されず、マスク表示(`****` +末尾 4 文字)のみです。DingTalk が最初の完全なガイド付きチャンネルです:[open-dev.dingtalk.com](https://open-dev.dingtalk.com/) でアプリを作成し、ボット機能を追加して Stream Mode を有効化(公開コールバック URL 不要)、AppKey を Client ID、AppSecret を Client Secret にコピーしてアプリを公開します。QQ も完全ガイド付きチャンネルになりました:QQ公式プラットフォーム([q.qq.com](https://q.qq.com/))でボットを登録し、AppID と AppSecret をフォームにコピーし、保存前に **Test connection** で検証してから有効化します(公開コールバック URL 不要、公式 botpy SDK による WebSocket 接続)。Email と WebSocket も完全ガイド付きチャンネルとして加わりました:Email の **Test connection** は何も送信せずに、IMAP ログイン・メールボックスフォルダ・SMTP ログインをプロバイダーに対して検証し、一方 WebSocket は(リモートサービスではなくローカルサーバーであるため)、このマシン上の TLS 証明書/鍵マテリアルとアドレスの使用可否を検証します。稼働中のサーバーがすでに確保しているアドレスは、失敗ではなく想定された状態として扱われます。WebSocket 設定を保存するとサーバーがホットスワップされるため、接続中のクライアント(Web UI のチャットを含む)は一時切断され、再接続します。**Test connection** は保存前にフォームの入力値だけで接続を試み、正直な結果コード(`ok` / `invalid_credentials` / `network` / `unsupported`)を返します。**Enable** は即適用:稼働中の channel runtime はプロセス再起動なしに該当アダプターのみをホットスワップし、有効化時に認証情報を自動検証します(検証失敗後に明示的に **Enable anyway** を選んだ場合のみスキップ)。無効化しても保存済み認証情報は残るため、再有効化時に再入力は不要です。ここには組み込みアダプターのみが表示されます。entry points 経由のプラグインチャンネルは引き続きファイルで設定します。 保存は `~/.vibe-trading/agent.json` の `channels.` をアトミックに更新します。YAML 設定ファイルは Web UI から読み取り専用です(ブラウザ編集には JSON が必要で、パネルはエラーの代わりにその旨を表示します)。専用ガイドのないチャンネルはデフォルト設定から生成された汎用フォームを描画します。 diff --git a/README_ko.md b/README_ko.md index 23034daf..5e234352 100644 --- a/README_ko.md +++ b/README_ko.md @@ -1035,7 +1035,7 @@ Built-in adapters는 `websocket`, `telegram`, `slack`, `discord`, `matrix`, `wha 명령어는 대소문자를 구분하지 않으며, 전체 메시지로 전송해야 합니다 (예: `hello /new`은 초기화가 아닌 일반 메시지로 처리됩니다). -**Web UI에서 설정**: Settings 페이지의 **IM Channels** 패널에서는 파일을 직접 편집하지 않고도 채널을 설정할 수 있습니다. 채널을 펼치면 설정 패널이 열립니다. 필드는 백엔드 메타데이터에서 렌더링되고, secret 값은 브라우저에 절대 반환되지 않으며 마스킹된 형태(`****` + 마지막 4자리)로만 표시됩니다. DingTalk이 첫 번째로 완전한 가이드를 제공하는 채널입니다: [open-dev.dingtalk.com](https://open-dev.dingtalk.com/)에서 앱 생성 → 봇 기능 추가 후 Stream Mode 활성화(공개 콜백 URL 불필요) → AppKey를 Client ID에, AppSecret을 Client Secret에 복사 → 앱 게시. QQ도 이제 완전한 가이드를 제공하는 채널입니다: QQ 오픈 플랫폼([q.qq.com](https://q.qq.com/))에서 봇을 등록하고 AppID와 AppSecret을 폼에 입력한 뒤, 저장 전에 **Test connection**으로 검증하고 활성화하면 됩니다(공개 콜백 URL 불필요, 공식 botpy SDK를 통한 WebSocket 연결). **Test connection**은 저장 전에 폼의 현재 입력값만으로 연결을 시도하고 정확한 결과 코드(`ok`, `invalid_credentials`, `network`, `unsupported`)를 반환합니다. **Enable**은 즉시 적용됩니다: 실행 중인 채널 런타임은 프로세스 재시작 없이 해당 어댑터만 핫 스왑하며, 활성화 시 자격 증명을 자동 검증합니다(검증 실패 후 명시적으로 **Enable anyway**를 선택한 경우에만 스킵). 비활성화해도 저장된 자격 증명은 유지되므로 재활성화 시 재입력이 필요 없습니다. 여기에는 내장 어댑터만 표시되며, entry points로 추가된 플러그인 채널은 계속 파일에서 설정합니다. +**Web UI에서 설정**: Settings 페이지의 **IM Channels** 패널에서는 파일을 직접 편집하지 않고도 채널을 설정할 수 있습니다. 채널을 펼치면 설정 패널이 열립니다. 필드는 백엔드 메타데이터에서 렌더링되고, secret 값은 브라우저에 절대 반환되지 않으며 마스킹된 형태(`****` + 마지막 4자리)로만 표시됩니다. DingTalk이 첫 번째로 완전한 가이드를 제공하는 채널입니다: [open-dev.dingtalk.com](https://open-dev.dingtalk.com/)에서 앱 생성 → 봇 기능 추가 후 Stream Mode 활성화(공개 콜백 URL 불필요) → AppKey를 Client ID에, AppSecret을 Client Secret에 복사 → 앱 게시. QQ도 이제 완전한 가이드를 제공하는 채널입니다: QQ 오픈 플랫폼([q.qq.com](https://q.qq.com/))에서 봇을 등록하고 AppID와 AppSecret을 폼에 입력한 뒤, 저장 전에 **Test connection**으로 검증하고 활성화하면 됩니다(공개 콜백 URL 불필요, 공식 botpy SDK를 통한 WebSocket 연결). Email과 WebSocket도 이제 완전한 가이드를 제공하는 채널입니다: Email의 **Test connection**은 아무것도 전송하지 않고 IMAP 로그인, 메일함 폴더, SMTP 로그인을 서비스 공급자에 대해 시도하며, WebSocket은 원격 서비스가 아닌 로컬 서버이므로 이 머신에서 TLS 인증서/키 소재와 주소 사용 가능성을 검증합니다 — 이미 실행 중인 서버가 점유한 주소는 실패가 아니라 예상된 상태로 표시됩니다. WebSocket 설정을 저장하면 서버가 핫 스왑되므로, 연결된 클라이언트(Web UI 채팅 포함)가 잠시 끊겼다가 다시 연결됩니다. **Test connection**은 저장 전에 폼의 현재 입력값만으로 연결을 시도하고 정확한 결과 코드(`ok`, `invalid_credentials`, `network`, `unsupported`)를 반환합니다. **Enable**은 즉시 적용됩니다: 실행 중인 채널 런타임은 프로세스 재시작 없이 해당 어댑터만 핫 스왑하며, 활성화 시 자격 증명을 자동 검증합니다(검증 실패 후 명시적으로 **Enable anyway**를 선택한 경우에만 스킵). 비활성화해도 저장된 자격 증명은 유지되므로 재활성화 시 재입력이 필요 없습니다. 여기에는 내장 어댑터만 표시되며, entry points로 추가된 플러그인 채널은 계속 파일에서 설정합니다. 저장은 `~/.vibe-trading/agent.json`의 `channels.` 섹션을 원자적으로 업데이트합니다. YAML 설정 파일은 Web UI에서 읽기 전용입니다(브라우저 편집에는 JSON이 필요하며, 패널은 오류 대신 그 사실을 표시합니다). 전용 가이드가 없는 채널은 기본 설정에서 파생된 일반 폼을 렌더링합니다. diff --git a/README_zh.md b/README_zh.md index d071f971..9d4a5ae6 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1039,7 +1039,7 @@ vibe-trading channels pairing --channel telegram list 命令不区分大小写,且必须作为整条消息发送(例如 `hello /new` 会被当作普通消息而非重置命令)。 -**从 Web UI 配置**:Settings 页面的 **IM Channels** 面板可以直接在网页上完成通道配置,无需手工编辑文件。展开某个通道即打开配置面板:表单字段由后端元数据渲染;secret 值不会回传给浏览器,只显示掩码(`****` 加末 4 位)。钉钉是第一个带完整接入指南的通道:在 [open-dev.dingtalk.com](https://open-dev.dingtalk.com/) 创建应用,添加机器人能力并开启 Stream Mode(无需公网回调地址),把 AppKey 填入 Client ID、AppSecret 填入 Client Secret,然后发布应用。QQ 也已加入带完整接入指南的通道:在 QQ 开放平台([q.qq.com](https://q.qq.com/))注册机器人,把 AppID 与 AppSecret 填入表单,保存前先用 **Test connection** 验证,再启用即可(无需公网回调地址,通过官方 botpy SDK 建立 WebSocket 连接)。**Test connection** 在任何内容保存之前就用表单中的当前值发起探测,并返回真实结果码(`ok`、`invalid_credentials`、`network`、`unsupported`)。打开 **Enable** 立即生效:运行中的通道 runtime 只热替换该通道的适配器,无需重启进程;启用时会自动校验凭据,除非你在校验失败后显式选择 **Enable anyway** 跳过。停用通道不会删除已保存的凭据,重新启用无需再次录入。此处仅列出内置适配器;通过 entry points 接入的插件频道仍需编辑配置文件。 +**从 Web UI 配置**:Settings 页面的 **IM Channels** 面板可以直接在网页上完成通道配置,无需手工编辑文件。展开某个通道即打开配置面板:表单字段由后端元数据渲染;secret 值不会回传给浏览器,只显示掩码(`****` 加末 4 位)。钉钉是第一个带完整接入指南的通道:在 [open-dev.dingtalk.com](https://open-dev.dingtalk.com/) 创建应用,添加机器人能力并开启 Stream Mode(无需公网回调地址),把 AppKey 填入 Client ID、AppSecret 填入 Client Secret,然后发布应用。QQ 也已加入带完整接入指南的通道:在 QQ 开放平台([q.qq.com](https://q.qq.com/))注册机器人,把 AppID 与 AppSecret 填入表单,保存前先用 **Test connection** 验证,再启用即可(无需公网回调地址,通过官方 botpy SDK 建立 WebSocket 连接)。Email 与 WebSocket 也已加入带完整接入指南的通道:Email 的 **Test connection** 会在不发送任何内容的前提下,向你的服务商探测 IMAP 登录、邮箱文件夹与 SMTP 登录;而 WebSocket——本机服务器而非远程服务——校验的是本机的 TLS 证书/密钥材料与地址可用性,地址若已被正在运行的服务器占用,会被视为预期状态而非失败。保存 WebSocket 配置会热替换服务器,因此已连接的客户端(包括 Web UI 聊天)会短暂断开并重新连接。**Test connection** 在任何内容保存之前就用表单中的当前值发起探测,并返回真实结果码(`ok`、`invalid_credentials`、`network`、`unsupported`)。打开 **Enable** 立即生效:运行中的通道 runtime 只热替换该通道的适配器,无需重启进程;启用时会自动校验凭据,除非你在校验失败后显式选择 **Enable anyway** 跳过。停用通道不会删除已保存的凭据,重新启用无需再次录入。此处仅列出内置适配器;通过 entry points 接入的插件频道仍需编辑配置文件。 保存会原子地更新 `~/.vibe-trading/agent.json` 的 `channels.` 段。YAML 配置文件对 Web UI 只读(浏览器内编辑要求 JSON),面板会直接说明这一点而不是报错。没有专属指南的通道会按其默认配置渲染通用表单。 diff --git a/agent/src/api/channels_config_routes.py b/agent/src/api/channels_config_routes.py index d0f27160..c8b84150 100644 --- a/agent/src/api/channels_config_routes.py +++ b/agent/src/api/channels_config_routes.py @@ -32,7 +32,11 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError from src.api import state as _state from src.channels import config as _channels_config from src.channels.bus.queue import MessageBus -from src.channels.config_meta import SECRET_KEY_RE, channel_field_hints, split_values_secrets +from src.channels.config_meta import ( + channel_field_hints, + is_secret_key, + split_values_secrets, +) from src.channels.registry import ( discover_channel_names, inspect_channel, @@ -40,7 +44,7 @@ from src.channels.registry import ( load_channel_class, ) from src.config import writer as _config_writer -from src.config.paths import get_config_path +from src.config.paths import get_config_path, get_workspace_path logger = logging.getLogger(__name__) @@ -114,9 +118,7 @@ def _known_channel_names() -> set[str]: def _is_secret_key(name: str, key: str) -> bool: """Return whether a config key must never cross the wire in clear.""" - if SECRET_KEY_RE.search(key): - return True - return any(hint["key"] == key and hint["secret"] for hint in channel_field_hints(name)) + return is_secret_key(name, key) def _stored_section(name: str) -> dict[str, Any]: @@ -257,6 +259,23 @@ def _reject_validation(fields: list[str]) -> HTTPException: ) +def _ephemeral_kwargs(name: str) -> dict[str, Any]: + """Return adapter-specific constructor kwargs for ephemeral instances. + + Mirrors ``ChannelManager._build_channel_kwargs``: ``WebSocketChannel`` + requires a keyword-only ``gateway``. The ephemeral instance is throwaway + (validation + probe only, it never serves), so a bare + ``build_gateway_services`` bundle — all in-memory dataclasses, no + filesystem or network side effects — is sufficient and + ``session_manager`` / ``cron_service`` may stay ``None``. + """ + if name == "websocket": + from src.channelsui.gateway_services import build_gateway_services + + return {"gateway": build_gateway_services(workspace_path=get_workspace_path())} + return {} + + def _build_ephemeral(name: str, section: dict[str, Any]) -> tuple[type, Any]: """Construct a throwaway adapter instance to validate *section*. @@ -269,7 +288,7 @@ def _build_ephemeral(name: str, section: dict[str, Any]) -> tuple[type, Any]: except Exception as exc: # noqa: BLE001 - an unloadable adapter cannot validate raise _reject_validation([type(exc).__name__]) from None try: - return cls, cls(section, MessageBus()) + return cls, cls(section, MessageBus(), **_ephemeral_kwargs(name)) except ValidationError as exc: raise _reject_validation(_validation_fields(exc)) from None except Exception as exc: # noqa: BLE001 - any construction error is a config error @@ -396,7 +415,7 @@ async def _run_test(name: str, body: dict[str, Any] | None, clears: list[str]) - raise _reject_validation(unknown) try: - instance = cls(merged, MessageBus()) + instance = cls(merged, MessageBus(), **_ephemeral_kwargs(name)) except ValidationError as exc: detail = "validation_error: " + ", ".join(_validation_fields(exc)) return _result(False, "invalid_credentials", detail, sdk_fallback) diff --git a/agent/src/api/security.py b/agent/src/api/security.py index faec2058..198922e4 100644 --- a/agent/src/api/security.py +++ b/agent/src/api/security.py @@ -642,6 +642,9 @@ async def require_settings_write_auth( cred: Optional[HTTPAuthorizationCredentials] = Security(_security), ) -> None: """Require explicit authorization before changing credential-routing settings.""" + if request.method.upper() not in _SAFE_BROWSER_METHODS: + _reject_cross_site_browser_request(request) + api_key = _configured_api_key() if api_key: token = _auth_credential_from_header_or_query(cred, None, allow_query=False) diff --git a/agent/src/channels/config_meta.py b/agent/src/channels/config_meta.py index a5df3e5d..054ce62c 100644 --- a/agent/src/channels/config_meta.py +++ b/agent/src/channels/config_meta.py @@ -6,9 +6,15 @@ i18n-friendly metadata (labels are owned by the frontend via ``help_key``); channels without hints fall back to their adapter's ``default_config()`` with type inference and a fail-safe secret heuristic. -Secret detection is the extended :data:`SECRET_KEY_RE` family plus per-channel -hint flags, so credential-shaped keys such as Feishu's ``encrypt_key`` and -Discord's ``proxy_username`` never cross the wire in ``values``. URL userinfo +Secret detection runs in two directions. A hand-written hint's ``secret`` +flag is *authoritative* for the keys it covers — an audited declaration may +add to the regex (a credential-shaped key the pattern misses) or subtract +from it (a benign key the pattern over-matches, e.g. websocket's +``token_ttl_s``). For every key no hand-written hint covers — derived-hint +channels, extra stored keys, manager overrides, unknown channels — the +extended :data:`SECRET_KEY_RE` family is the *unconditional* fail-safe, so +credential-shaped keys such as Feishu's ``encrypt_key`` and Discord's +``proxy_username`` never cross the wire in ``values``. URL userinfo (``user:password@``) is stripped from non-secret values before they are returned. """ @@ -41,7 +47,11 @@ class FieldHint(TypedDict): key: Config key as the adapter serializes it (aliases already applied). type: Widget type: ``text``, ``password``, ``bool`` or ``list``. secret: Whether the value must never cross the wire in ``values``. - required: Whether the form must not submit an empty value. + For hand-written hints this flag is authoritative (see + :func:`is_secret_key`). + required: Decorative form affordance only: renders an asterisk and a + "required" hint next to the field. The frontend does not gate + submission on it (verified against ``ChannelConfigPanel.tsx``). help_key: i18n key for the frontend label, or ``None`` when the channel has no hand-written label. """ @@ -100,9 +110,103 @@ def _qq_hints() -> list[FieldHint]: ] +def _email_hints() -> list[FieldHint]: + """Return the hand-written Email field hints (``enabled`` excluded). + + ``required`` decision: the frontend renders ``required`` as a decorative + asterisk + hint only (``ChannelConfigPanel.tsx`` never gates submission on + it, for enabled or disabled channels alike), so marking the six credential + fields required is safe and consistent with ``EmailChannel._validate_config`` + (which refuses to start without all six) and with the DingTalk/QQ precedent. + """ + specs = ( + ("consent_granted", "bool", False, False), + ("imap_host", "text", False, True), + ("imap_port", "text", False, False), + ("imap_username", "text", False, True), + ("imap_password", "password", True, True), + ("imap_mailbox", "text", False, False), + ("imap_use_ssl", "bool", False, False), + ("smtp_host", "text", False, True), + ("smtp_port", "text", False, False), + ("smtp_username", "text", False, True), + ("smtp_password", "password", True, True), + ("smtp_use_tls", "bool", False, False), + ("smtp_use_ssl", "bool", False, False), + ("verify_tls", "bool", False, False), + ("from_address", "text", False, False), + ("auto_reply_enabled", "bool", False, False), + ("poll_interval_seconds", "text", False, False), + ("mark_seen", "bool", False, False), + ("post_action", "text", False, False), + ("post_action_move_mailbox", "text", False, False), + ("post_action_expunge", "bool", False, False), + ("post_action_ignore_skipped", "bool", False, False), + ("max_body_chars", "text", False, False), + ("subject_prefix", "text", False, False), + ("allow_from", "list", False, False), + ("verify_dkim", "bool", False, False), + ("verify_spf", "bool", False, False), + ("allowed_attachment_types", "list", False, False), + ("max_attachment_size", "text", False, False), + ("max_attachments_per_email", "text", False, False), + ) + return [ + { + "key": key, + "type": widget, + "secret": secret, + "required": required, + "help_key": f"{_HELP_KEY_PREFIX}.email.{key}", + } + for key, widget, secret, required in specs + ] + + +def _websocket_hints() -> list[FieldHint]: + """Return the hand-written WebSocket field hints (``enabled`` excluded). + + ``required`` decision: nothing is unconditionally required — host/port/path + carry working defaults and ``unix_socket_path`` is a legitimate alternative + to host/port, so every field stays ``required=False``. ``ssl_certfile`` / + ``ssl_keyfile`` are filesystem paths, not secret material (non-secret is + correct); ``token`` / ``token_issue_secret`` are the secrets. + """ + specs = ( + ("host", "text", False, False), + ("port", "text", False, False), + ("unix_socket_path", "text", False, False), + ("path", "text", False, False), + ("token", "password", True, False), + ("token_issue_path", "text", False, False), + ("token_issue_secret", "password", True, False), + ("token_ttl_s", "text", False, False), + ("websocket_requires_token", "bool", False, False), + ("allow_from", "list", False, False), + ("streaming", "bool", False, False), + ("max_message_bytes", "text", False, False), + ("ping_interval_s", "text", False, False), + ("ping_timeout_s", "text", False, False), + ("ssl_certfile", "text", False, False), + ("ssl_keyfile", "text", False, False), + ) + return [ + { + "key": key, + "type": widget, + "secret": secret, + "required": required, + "help_key": f"{_HELP_KEY_PREFIX}.websocket.{key}", + } + for key, widget, secret, required in specs + ] + + FIELD_HINTS: dict[str, list[FieldHint]] = { "dingtalk": _dingtalk_hints(), + "email": _email_hints(), "qq": _qq_hints(), + "websocket": _websocket_hints(), } @@ -172,6 +276,24 @@ def channel_field_hints(name: str) -> list[FieldHint]: return _derive_hints(name) +def is_secret_key(name: str, key: str) -> bool: + """Return whether *key* holds a credential for channel *name*. + + A hand-written hint's ``secret`` flag is authoritative for the keys it + covers (an audited declaration may subtract from the regex fail-safe, + e.g. websocket's ``token_ttl_s``); derived hints carry the regex verdict + themselves, and any key no hand-written hint covers — extra stored keys, + manager overrides, unknown channels — falls back to the unconditional + :data:`SECRET_KEY_RE` fail-safe. + """ + hints = FIELD_HINTS.get(name) + if hints is not None: + for hint in hints: + if hint["key"] == key: + return bool(hint["secret"]) + return bool(SECRET_KEY_RE.search(key)) + + def _mask(value: Any) -> dict[str, Any]: """Return the ``{set, masked}`` descriptor for one secret value.""" is_set = bool(value) @@ -203,9 +325,11 @@ def split_values_secrets( ) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: """Split a raw config section into non-secret values and masked secrets. - A key is treated as a secret when it matches :data:`SECRET_KEY_RE` or is - marked secret in :func:`channel_field_hints`. The strip from ``values`` is - unconditional, so an unknown channel's ``webhook_secret`` never leaks. + Each key is classified via :func:`is_secret_key`: a hand-written hint's + ``secret`` flag is authoritative for the keys it covers, and + :data:`SECRET_KEY_RE` is the unconditional fail-safe for every key no + hand-written hint covers, so an unknown channel's ``webhook_secret`` + never leaks. Args: name: Channel module name. @@ -217,13 +341,10 @@ def split_values_secrets( ``{"set": bool, "masked": "****"}`` (a suffix is disclosed only for values longer than 8 characters). """ - secret_hint_keys = { - hint["key"] for hint in channel_field_hints(name) if hint["secret"] - } values: dict[str, Any] = {} secrets: dict[str, dict[str, Any]] = {} for key, value in section.items(): - if SECRET_KEY_RE.search(key) or key in secret_hint_keys: + if is_secret_key(name, key): secrets[key] = _mask(value) else: values[key] = _strip_url_userinfo(value) diff --git a/agent/src/channels/email.py b/agent/src/channels/email.py index 2b84a51a..f3960670 100644 --- a/agent/src/channels/email.py +++ b/agent/src/channels/email.py @@ -22,12 +22,13 @@ from typing import Any, Literal import logging; logger = logging.getLogger(__name__) from pydantic import Field +from src.channels import email_probe from src.channels.bus.events import OutboundMessage from src.channels.bus.queue import MessageBus from src.channels.base import BaseChannel from src.channels.utils import get_media_dir from pydantic import BaseModel -from src.channels.utils import safe_filename +from src.channels.utils import email_tls_context, safe_filename, send_imap_id class EmailConfig(BaseModel): @@ -49,6 +50,9 @@ class EmailConfig(BaseModel): smtp_password: str = "" smtp_use_tls: bool = True smtp_use_ssl: bool = False + # TLS certificate verification for implicit-SSL (IMAP4_SSL/SMTP_SSL) + # connections; set False only for self-signed / internal-CA mail servers. + verify_tls: bool = True from_address: str = "" auto_reply_enabled: bool = True @@ -93,6 +97,7 @@ class EmailChannel(BaseChannel): name = "email" display_name = "Email" + supports_connection_test = True _IMAP_MONTHS = ( "Jan", "Feb", @@ -194,7 +199,12 @@ class EmailChannel(BaseChannel): if should_apply_post_action and not self.config.post_action_ignore_skipped: post_actions_uids.update(skipped_uids) - if post_actions_uids: + # stop() is flag-only: an in-flight to_thread fetch completes and + # its already-fetched batch is still delivered above (dropping it + # would LOSE messages already marked seen during fetch), but the + # destructive delete/move post-actions of a stale config are + # skipped once stopped — non-destructive wins. + if post_actions_uids and self._running: await asyncio.to_thread(self._apply_post_actions_batch, sorted(post_actions_uids)) except Exception: self.logger.exception("Polling error") @@ -207,6 +217,14 @@ class EmailChannel(BaseChannel): """Stop polling loop.""" self._running = False + async def test_connection(self) -> dict[str, Any]: + """Validate the email credentials with a standalone IMAP + SMTP probe. + + Delegates to :func:`src.channels.email_probe.test_connection`; see + that function for the full contract (codes, scrubbing, no sending). + """ + return await email_probe.test_connection(self.config) + async def send(self, msg: OutboundMessage) -> None: """Send email via SMTP.""" if not self.config.consent_granted: @@ -340,6 +358,7 @@ class EmailChannel(BaseChannel): self.config.smtp_host, self.config.smtp_port, timeout=timeout, + ssl_context=email_tls_context(self.config.verify_tls), ) as smtp: smtp.login(self.config.smtp_username, self.config.smtp_password) smtp.send_message(msg) @@ -559,12 +578,19 @@ class EmailChannel(BaseChannel): def _open_imap_client(self, mailbox: str, *, missing_mailbox_ok: bool = False) -> Any | None: if self.config.imap_use_ssl: - client: Any = imaplib.IMAP4_SSL(self.config.imap_host, self.config.imap_port) + client: Any = imaplib.IMAP4_SSL( + self.config.imap_host, + self.config.imap_port, + ssl_context=email_tls_context(self.config.verify_tls), + ) else: client = imaplib.IMAP4(self.config.imap_host, self.config.imap_port) try: client.login(self.config.imap_username, self.config.imap_password) + # NetEase (163/126/yeah.net) rejects SELECT with "Unsafe Login" + # unless the client sent an IMAP ID first; harmless elsewhere. + send_imap_id(client) try: status, _ = client.select(mailbox) except Exception as exc: diff --git a/agent/src/channels/email_probe.py b/agent/src/channels/email_probe.py new file mode 100644 index 00000000..ffbd75ff --- /dev/null +++ b/agent/src/channels/email_probe.py @@ -0,0 +1,197 @@ +"""Standalone email credential probe, split from ``email.py`` for size. + +The probe validates a (possibly unsaved) IMAP + SMTP credential set without +touching the channel's polling loop or send path. Everything here is +stateless: functions take the ``EmailConfig`` explicitly so the module never +imports the adapter at runtime (only under ``TYPE_CHECKING``). Unlike the +token-endpoint probes (:mod:`src.channels.token_probe`), email is stdlib-only +(``imaplib`` / ``smtplib``), so ``sdk_available`` is always True and the +blocking transports are driven through :func:`asyncio.to_thread`. + +Classification mirrors the adapter's own transport semantics +(:meth:`EmailChannel._open_imap_client` and :meth:`EmailChannel._smtp_send`): + +- IMAP: ``imap_use_ssl`` → ``IMAP4_SSL`` else ``IMAP4``; a connect failure is + ``network``, a rejected login is ``invalid_credentials``, and a mailbox + that cannot be selected is ``invalid_credentials`` naming the mailbox. +- SMTP: ``smtp_use_ssl`` → ``SMTP_SSL`` else ``SMTP`` + ``starttls`` when + ``smtp_use_tls``; an authentication rejection is ``invalid_credentials``, + every other transport failure is ``network``. + +Details are prefixed with the failing side (``imap:`` / ``smtp:``), bounded to +200 characters and scrubbed of both the password and the username, so a +server that echoes the credential in its rejection never leaks it into a +result envelope or a log line. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import imaplib +import smtplib +import ssl +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any + +from src.channels.token_probe import scrub_secrets +from src.channels.utils import email_tls_context, send_imap_id + +if TYPE_CHECKING: + from src.channels.email import EmailConfig + +_CONNECT_TIMEOUT_S = 10 +_DETAIL_LIMIT = 200 + +# Bound at import: ``imaplib.IMAP4.error`` is a stable exception class +# (``.abort`` is a subclass), and resolving it through the ``IMAP4`` attribute +# at call time would break whenever the class itself is replaced (tests). +_IMAP4_ERROR = imaplib.IMAP4.error + +_CREDENTIAL_FIELDS = ( + "imap_host", + "imap_username", + "imap_password", + "smtp_host", + "smtp_username", + "smtp_password", +) + + +def _bounded(side: str, exc: BaseException, secrets: Iterable[str]) -> str: + """Return a scrubbed, bounded ``: `` detail string.""" + text = str(exc) or type(exc).__name__ + return scrub_secrets(f"{side}: {text}", secrets)[:_DETAIL_LIMIT] + + +def _select_reason(status: str, data: Any) -> str: + """Extract a human-readable reason from an IMAP ``select`` response.""" + if isinstance(data, (list, tuple)): + for item in data: + if isinstance(item, (bytes, bytearray)) and item.strip(): + return bytes(item).decode("utf-8", errors="replace").strip() + if isinstance(item, str) and item.strip(): + return item.strip() + return status + + +def _probe_imap(config: EmailConfig) -> tuple[str, str] | None: + """Login + mailbox-select over IMAP; return ``(code, detail)`` or None.""" + secrets = (config.imap_password, config.imap_username) + mailbox = config.imap_mailbox or "INBOX" + client: imaplib.IMAP4 | None = None + try: + try: + if config.imap_use_ssl: + client = imaplib.IMAP4_SSL( + config.imap_host, + config.imap_port, + timeout=_CONNECT_TIMEOUT_S, + ssl_context=email_tls_context(config.verify_tls), + ) + else: + client = imaplib.IMAP4( + config.imap_host, config.imap_port, timeout=_CONNECT_TIMEOUT_S + ) + except (OSError, _IMAP4_ERROR) as exc: + return "network", _bounded("imap", exc, secrets) + + try: + client.login(config.imap_username, config.imap_password) + except _IMAP4_ERROR as exc: # IMAP4.abort is a subclass + return "invalid_credentials", _bounded("imap", exc, secrets) + except OSError as exc: + return "network", _bounded("imap", exc, secrets) + + # NetEase requires an IMAP ID before SELECT; harmless elsewhere. + send_imap_id(client) + + try: + status, data = client.select(mailbox) + except Exception as exc: # noqa: BLE001 - any select failure names the mailbox + reason = str(exc) or type(exc).__name__ + detail = f"imap mailbox select failed: {mailbox} ({reason})" + return "invalid_credentials", scrub_secrets(detail, secrets)[:_DETAIL_LIMIT] + if status != "OK": + detail = f"imap mailbox select failed: {mailbox} ({_select_reason(status, data)})" + return "invalid_credentials", scrub_secrets(detail, secrets)[:_DETAIL_LIMIT] + finally: + if client is not None: + with contextlib.suppress(Exception): + client.logout() + return None + + +def _probe_smtp(config: EmailConfig) -> tuple[str, str] | None: + """Connect (+ optional STARTTLS) and login over SMTP; ``(code, detail)`` or None.""" + secrets = (config.smtp_password, config.smtp_username) + client: smtplib.SMTP | None = None + try: + try: + if config.smtp_use_ssl: + client = smtplib.SMTP_SSL( + config.smtp_host, + config.smtp_port, + timeout=_CONNECT_TIMEOUT_S, + ssl_context=email_tls_context(config.verify_tls), + ) + else: + client = smtplib.SMTP( + config.smtp_host, config.smtp_port, timeout=_CONNECT_TIMEOUT_S + ) + if config.smtp_use_tls: + client.starttls(context=ssl.create_default_context()) + except (smtplib.SMTPException, OSError) as exc: + # Covers SMTPConnectError / SMTPServerDisconnected / socket.timeout; + # a transport that cannot be established or upgraded is network-class. + return "network", _bounded("smtp", exc, secrets) + + try: + client.login(config.smtp_username, config.smtp_password) + except smtplib.SMTPAuthenticationError as exc: + return "invalid_credentials", _bounded("smtp", exc, secrets) + except (smtplib.SMTPException, OSError) as exc: + return "network", _bounded("smtp", exc, secrets) + finally: + if client is not None: + with contextlib.suppress(Exception): + client.quit() + return None + + +async def test_connection(config: EmailConfig) -> dict[str, Any]: + """Validate the email credentials with standalone IMAP + SMTP probes. + + Uses fresh ``imaplib`` / ``smtplib`` connections rather than the channel's + polling loop (which only exists after :meth:`EmailChannel.start`), so an + unsaved credential set can be checked before the channel is started. The + SMTP half runs only after the IMAP half passes, and no message is ever + sent: the probe logs in and selects a mailbox, nothing more. + + Args: + config: The email credential set to validate. + + Returns: + A JSON-serializable envelope with ``ok`` and a ``code`` of ``ok`` / + ``invalid_credentials`` / ``network``, plus ``sdk_available`` (always + True — the probe is stdlib-only). Any ``detail`` is bounded to 200 + characters and scrubbed of credential values. + """ + missing = [field for field in _CREDENTIAL_FIELDS if not getattr(config, field)] + if missing: + # Field NAMES are not secrets; naming them tells the operator exactly + # which half (IMAP/SMTP) of the six-field credential set is incomplete. + return { + "ok": False, + "code": "invalid_credentials", + "detail": "missing credentials: " + ", ".join(missing), + "sdk_available": True, + } + + failure = await asyncio.to_thread(_probe_imap, config) + if failure is None: + failure = await asyncio.to_thread(_probe_smtp, config) + if failure is not None: + code, detail = failure + return {"ok": False, "code": code, "detail": detail, "sdk_available": True} + return {"ok": True, "code": "ok", "sdk_available": True} diff --git a/agent/src/channels/registry.py b/agent/src/channels/registry.py index b261ee29..77a366c4 100644 --- a/agent/src/channels/registry.py +++ b/agent/src/channels/registry.py @@ -27,6 +27,7 @@ _INTERNAL = frozenset( "config_meta", "dingtalk_media", "dingtalk_probe", + "email_probe", "manager", "pairing", "qq_probe", @@ -35,6 +36,7 @@ _INTERNAL = frozenset( "targets", "token_probe", "utils", + "websocket_probe", } ) _LEGACY_GLOBAL_CONFIG_KEYS = frozenset( diff --git a/agent/src/channels/utils.py b/agent/src/channels/utils.py index cd6ef1e1..c3247181 100644 --- a/agent/src/channels/utils.py +++ b/agent/src/channels/utils.py @@ -3,15 +3,26 @@ from __future__ import annotations import ipaddress +import logging import re import socket +import ssl from pathlib import Path +from typing import Any from urllib.parse import urlparse from src.config.paths import get_data_dir +logger = logging.getLogger(__name__) + _UNSAFE_CHARS = re.compile(r"[/\\:*?\"<>|]") +# Static client identification for the RFC 2971 IMAP ``ID`` command. NetEase +# mailboxes (163/126/yeah.net) accept ``LOGIN`` but reject the first ``SELECT`` +# with ``Unsafe Login`` unless the client has identified itself; other servers +# accept or ignore ``ID``. No user data or secrets are sent. +_IMAP_CLIENT_ID = '("name" "vibe-trading" "version" "1.0" "vendor" "HKUDS")' + def get_media_dir(channel_name: str) -> Path: """Return a media directory for *channel_name* under the VT uploads root. @@ -94,6 +105,44 @@ def safe_filename(name: str) -> str: return _UNSAFE_CHARS.sub("_", name).strip() +def send_imap_id(client: Any) -> None: + """Send a best-effort RFC 2971 IMAP ``ID`` command; never raises. + + NetEase mailboxes (163/126/yeah.net) accept ``LOGIN`` but reject the + first ``SELECT`` with ``Unsafe Login`` unless the client has sent an + ``ID`` command identifying itself. Sending ``ID`` is harmless for + servers that support it and ignored by those that do not, so this is + best-effort: any failure (unsupported command, closed socket) is + swallowed and the caller proceeds to ``SELECT`` regardless. + + Args: + client: A connected, logged-in ``imaplib.IMAP4`` or ``IMAP4_SSL``. + """ + try: + client.xatom("ID", _IMAP_CLIENT_ID) + except Exception: # noqa: BLE001 - best-effort; never block the connection + logger.debug("IMAP ID command failed (non-fatal)", exc_info=True) + + +def email_tls_context(verify: bool) -> ssl.SSLContext: + """Return a TLS context for implicit-SSL (IMAP4_SSL/SMTP_SSL) email connections. + + ``verify=True`` (the default) verifies the server certificate and hostname + against the system CA bundle, so a credential is never sent to an + unverified server. ``verify=False`` disables verification — an explicit, + documented opt-out for self-signed or internal-CA mail servers that trades + MITM protection for connectivity. Mirrors the STARTTLS path, which already + uses :func:`ssl.create_default_context`. + """ + if verify: + return ssl.create_default_context() + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + # check_hostname must be cleared before CERT_NONE or Python raises ValueError. + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + def validate_url_target(url: str, *, allow_loopback: bool = False) -> tuple[bool, str]: """Validate a URL is safe to fetch: scheme, hostname, and resolved IPs. diff --git a/agent/src/channels/websocket.py b/agent/src/channels/websocket.py index 23c07160..2e614b61 100644 --- a/agent/src/channels/websocket.py +++ b/agent/src/channels/websocket.py @@ -18,6 +18,7 @@ from websockets.asyncio.server import ServerConnection, serve, unix_serve from websockets.exceptions import ConnectionClosed from websockets.http11 import Request as WsRequest +from src.channels import websocket_probe from src.channels.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage from src.channels.bus.queue import MessageBus from src.channels.base import BaseChannel @@ -266,6 +267,7 @@ class WebSocketChannel(BaseChannel): name = "websocket" display_name = "WebSocket" + supports_connection_test = True def __init__( self, @@ -364,6 +366,14 @@ class WebSocketChannel(BaseChannel): def default_config(cls) -> dict[str, Any]: return WebSocketConfig().model_dump(by_alias=True) + async def test_connection(self) -> dict[str, Any]: + """Validate the WebSocket local configuration with a standalone probe. + + Delegates to :func:`src.channels.websocket_probe.test_connection`; see + that function for the full contract (SSL material, TCP bind, codes). + """ + return await websocket_probe.test_connection(self.config) + def _expected_path(self) -> str: return _normalize_config_path(self.config.path) diff --git a/agent/src/channels/websocket_probe.py b/agent/src/channels/websocket_probe.py new file mode 100644 index 00000000..52052078 --- /dev/null +++ b/agent/src/channels/websocket_probe.py @@ -0,0 +1,189 @@ +"""Standalone WebSocket channel probe — local validation, no remote endpoint. + +WebSocket is a *server* channel: vibe-trading listens and clients connect, so +there are no remote credentials to validate against a token endpoint (cf. +:mod:`src.channels.token_probe`). Instead this probe checks the two things +that make :meth:`WebSocketChannel.start` fail in practice: + +1. **SSL material** — when ``ssl_certfile`` / ``ssl_keyfile`` are set, the + cert/key pair must load into a server ``SSLContext`` (same construction as + the adapter's ``_build_ssl_context``). A missing or malformed file is + ``invalid_credentials``: it is bad local secret material, not a network + problem. +2. **Address availability** — a read-only TCP bind of ``host:port`` that + mirrors what ``serve()`` (via ``loop.create_server``) will do: every + unique resolved address is bound and held simultaneously (e.g. + ``localhost`` → ``::1`` + ``127.0.0.1``), with ``SO_REUSEADDR`` set only + where ``create_server`` sets it (POSIX, non-cygwin — on Windows the flag + would let the probe bind an occupied port and report a false ``ok``). + The honest semantics of ``EADDRINUSE``: while this channel is *running* + the address is legitimately occupied by us, so a plain bind would report + a false negative on exactly the hot-config Test click the probe exists + for. The probe therefore follows an ``EADDRINUSE`` with a plain TCP + connect: a listening server answers (ok, with an explanatory detail), + while a held but non-listening address does not (``network``). + +When ``unix_socket_path`` is set, ``start()`` ignores host/port entirely and +mkdirs the parent + unlinks a stale socket before binding — side effects a +read-only probe must never perform — so the bind check is skipped and only +the SSL material is validated. + +The probe performs no filesystem writes, creates no directories and unlinks +nothing. Details are scrubbed of ``token`` / ``token_issue_secret`` +defensively (cert/key *paths* may appear: they are not secrets) and bounded +to 200 characters. +""" + +from __future__ import annotations + +import asyncio +import errno +import os +import socket +import ssl +import sys +from typing import TYPE_CHECKING, Any + +from src.channels.token_probe import scrub_secrets + +if TYPE_CHECKING: + from src.channels.websocket import WebSocketConfig + +_DETAIL_LIMIT = 200 +_CONNECT_TIMEOUT_S = 3 + +_ADDRESS_IN_USE_DETAIL = ( + "address already in use by a listening server (expected while this " + "channel is running; otherwise another process holds host:port)" +) + + +def _websockets_available() -> bool: + """Return whether the ``websockets`` core dependency imports.""" + try: + import websockets # noqa: F401 + except ImportError: # pragma: no cover - websockets is a core dependency + return False + return True + + +def _check_ssl_material(config: WebSocketConfig) -> dict[str, Any] | None: + """Load the configured cert/key pair; return a failure envelope or None.""" + cert = config.ssl_certfile.strip() + key = config.ssl_keyfile.strip() + if not cert and not key: + return None + if not cert or not key: + # Same both-or-neither rule and message as the adapter's + # _build_ssl_context, surfaced before load_cert_chain can obscure it. + return { + "ok": False, + "code": "invalid_credentials", + "detail": ( + "ssl: ssl_certfile and ssl_keyfile must both be set for WSS, " + "or both left empty" + ), + } + try: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(certfile=cert, keyfile=key) + except (OSError, ssl.SSLError, ValueError) as exc: + return {"ok": False, "code": "invalid_credentials", "detail": f"ssl: {exc}"} + return None + + +def _check_listening(addr: Any) -> dict[str, Any]: + """Disambiguate ``EADDRINUSE``: a live listener is expected, not a failure.""" + try: + with socket.create_connection(addr, timeout=_CONNECT_TIMEOUT_S): + pass + except OSError as exc: + return { + "ok": False, + "code": "network", + "detail": f"bind: address in use but not accepting connections: {exc}", + } + return {"ok": True, "code": "ok", "detail": _ADDRESS_IN_USE_DETAIL} + + +def _check_tcp_bind(config: WebSocketConfig) -> dict[str, Any] | None: + """Resolve + bind ``host:port`` read-only; return an envelope or None.""" + if config.unix_socket_path: + # start() ignores host/port when a unix socket is configured, and + # preparing one (mkdir + unlink of a stale socket) is a write side + # effect this probe must not perform — nothing meaningful to check. + return None + try: + infos = socket.getaddrinfo(config.host, config.port, type=socket.SOCK_STREAM) + except OSError as exc: # includes socket.gaierror for unresolvable hosts + return {"ok": False, "code": "network", "detail": f"bind: {exc}"} + + # create_server's reuse_address default: POSIX only. On Windows the flag + # would let this bind succeed against an occupied port — a false ok while + # serve() itself fails with EADDRINUSE. + reuse_address = os.name == "posix" and sys.platform != "cygwin" + bound: list[socket.socket] = [] + try: + # Mirror create_server: dedupe the resolution and bind EVERY unique + # address simultaneously ("localhost" → ::1 + 127.0.0.1); serve() + # needs all of them, so the probe must not pass on a partial bind. + for family, _, _, _, addr in set(infos): + sock = socket.socket(family, socket.SOCK_STREAM) + bound.append(sock) + try: + if reuse_address: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(addr) + except OSError as exc: + if exc.errno == errno.EADDRINUSE: + return _check_listening(addr) + return {"ok": False, "code": "network", "detail": f"bind: {exc}"} + finally: + for sock in bound: + sock.close() + return None + + +async def test_connection(config: WebSocketConfig) -> dict[str, Any]: + """Validate the WebSocket channel's local configuration. + + Runs the SSL-material and TCP-bind checks in a worker thread (both are + blocking) and never starts a server. A success may carry a ``detail`` + when the address is already held by a listening server — the expected + state while this channel is running. + + Args: + config: The WebSocket configuration to validate. + + Returns: + A JSON-serializable envelope with ``ok`` and a ``code`` of ``ok`` / + ``invalid_credentials`` / ``network``, plus an ``sdk_available`` flag + echoing whether the ``websockets`` core dependency imports. Any + ``detail`` is bounded to 200 characters and scrubbed of token values. + """ + sdk_available = _websockets_available() + secrets = (config.token, config.token_issue_secret) + + note = "" + for check in (_check_ssl_material, _check_tcp_bind): + outcome = await asyncio.to_thread(check, config) + if outcome is None: + continue + detail = scrub_secrets(str(outcome.get("detail", "")), secrets)[:_DETAIL_LIMIT] + if not outcome.get("ok"): + return { + "ok": False, + "code": outcome["code"], + "detail": detail, + "sdk_available": sdk_available, + } + note = detail or note + + envelope: dict[str, Any] = { + "ok": True, + "code": "ok", + "sdk_available": sdk_available, + } + if note: + envelope["detail"] = note + return envelope diff --git a/agent/tests/test_channel_config_meta.py b/agent/tests/test_channel_config_meta.py index f9e63bc7..b32779a9 100644 --- a/agent/tests/test_channel_config_meta.py +++ b/agent/tests/test_channel_config_meta.py @@ -13,6 +13,7 @@ from src.channels import config_meta as _config_meta from src.channels.config_meta import ( SECRET_KEY_RE, channel_field_hints, + is_secret_key, split_values_secrets, ) from src.channels.registry import discover_channel_names, load_channel_class @@ -172,10 +173,188 @@ def test_qq_field_hints_exact_snapshot() -> None: assert "enabled" not in {hint["key"] for hint in hints} +# --- (a2) hand-written Email / WebSocket hints ------------------------------- # + +_EMAIL_HINT_KEYS = ( + "consent_granted", + "imap_host", + "imap_port", + "imap_username", + "imap_password", + "imap_mailbox", + "imap_use_ssl", + "smtp_host", + "smtp_port", + "smtp_username", + "smtp_password", + "smtp_use_tls", + "smtp_use_ssl", + "verify_tls", + "from_address", + "auto_reply_enabled", + "poll_interval_seconds", + "mark_seen", + "post_action", + "post_action_move_mailbox", + "post_action_expunge", + "post_action_ignore_skipped", + "max_body_chars", + "subject_prefix", + "allow_from", + "verify_dkim", + "verify_spf", + "allowed_attachment_types", + "max_attachment_size", + "max_attachments_per_email", +) + +_WEBSOCKET_HINT_KEYS = ( + "host", + "port", + "unix_socket_path", + "path", + "token", + "token_issue_path", + "token_issue_secret", + "token_ttl_s", + "websocket_requires_token", + "allow_from", + "streaming", + "max_message_bytes", + "ping_interval_s", + "ping_timeout_s", + "ssl_certfile", + "ssl_keyfile", +) + + +def test_email_field_hints_contract() -> None: + """Email hints: declaration order, secrets, required set, help_key prefix.""" + hints = channel_field_hints("email") + keys = tuple(hint["key"] for hint in hints) + assert keys == _EMAIL_HINT_KEYS + assert "enabled" not in keys + assert len(hints) == 30 + + by_key = {hint["key"]: hint for hint in hints} + assert {key for key, hint in by_key.items() if hint["secret"]} == { + "imap_password", + "smtp_password", + } + assert by_key["imap_password"]["type"] == "password" + assert by_key["smtp_password"]["type"] == "password" + # required mirrors EmailChannel._validate_config: the channel refuses to + # start without all six credential fields. + assert {key for key, hint in by_key.items() if hint["required"]} == { + "imap_host", + "imap_username", + "imap_password", + "smtp_host", + "smtp_username", + "smtp_password", + } + for hint in hints: + assert hint["help_key"] == f"settings.channels.fields.email.{hint['key']}" + + +def test_websocket_field_hints_contract() -> None: + """WebSocket hints: declaration order, secrets, help_key prefix.""" + hints = channel_field_hints("websocket") + keys = tuple(hint["key"] for hint in hints) + assert keys == _WEBSOCKET_HINT_KEYS + assert "enabled" not in keys + assert len(hints) == 16 + + by_key = {hint["key"]: hint for hint in hints} + assert {key for key, hint in by_key.items() if hint["secret"]} == { + "token", + "token_issue_secret", + } + assert by_key["token"]["type"] == "password" + assert by_key["token_issue_secret"]["type"] == "password" + # ssl_certfile/ssl_keyfile are filesystem paths, not secret material. + assert by_key["ssl_certfile"]["secret"] is False + assert by_key["ssl_keyfile"]["secret"] is False + # Nothing is unconditionally required: defaults work and unix_socket_path + # is a legitimate alternative to host/port. + assert {key for key, hint in by_key.items() if hint["required"]} == set() + for hint in hints: + assert hint["help_key"] == f"settings.channels.fields.websocket.{hint['key']}" + + +# --- (a3) hint-authoritative secret resolution ------------------------------- # + + +def test_is_secret_key_hint_authoritative_with_regex_failsafe() -> None: + """A hand-written hint's secret flag wins for the keys it covers only.""" + # Declared non-secret wins over the regex over-match... + assert is_secret_key("websocket", "token_issue_path") is False + assert is_secret_key("websocket", "token_ttl_s") is False + assert is_secret_key("websocket", "websocket_requires_token") is False + # ...and declared secrets stay secret. + assert is_secret_key("websocket", "token") is True + assert is_secret_key("websocket", "token_issue_secret") is True + assert is_secret_key("email", "imap_password") is True + assert is_secret_key("email", "smtp_password") is True + assert is_secret_key("dingtalk", "client_secret") is True + # An UNHINTED regex-matching key on a hand-written channel keeps the + # unconditional fail-safe... + assert is_secret_key("websocket", "webhook_secret") is True + # ...as do derived channels and unknown channels. + assert is_secret_key("telegram", "bot_token") is True + assert is_secret_key("no_such_channel", "api_key") is True + assert is_secret_key("telegram", "allow_from") is False + + +def test_websocket_token_shaped_non_secrets_land_in_values() -> None: + """The security-flip root cause: requires_token masked → form seeds False.""" + section = { + "enabled": False, + "websocket_requires_token": True, + "token_ttl_s": 300, + "token_issue_path": "/issue", + "token": "s3cret-value", + "token_issue_secret": "issue-secret-value", + "webhook_secret": "unhinted-but-regex-caught", + } + + values, secrets = split_values_secrets("websocket", section) + + assert values["websocket_requires_token"] is True + assert values["token_ttl_s"] == 300 + assert values["token_issue_path"] == "/issue" + assert set(secrets) == {"token", "token_issue_secret", "webhook_secret"} + assert secrets["token"] == {"set": True, "masked": "****alue"} + + +def test_email_and_dingtalk_masking_unchanged() -> None: + values, secrets = split_values_secrets( + "email", + {"imap_host": "h", "imap_password": "a", "smtp_password": "b"}, + ) + assert values == {"imap_host": "h"} + assert set(secrets) == {"imap_password", "smtp_password"} + + values, secrets = split_values_secrets( + "dingtalk", {"client_secret": "dummy-secret-1234"} + ) + assert values == {} + assert secrets == {"client_secret": {"set": True, "masked": "****1234"}} + + +def test_derived_channel_regex_masking_unchanged() -> None: + values, secrets = split_values_secrets( + "telegram", {"bot_token": "x", "allow_from": ["*"]} + ) + assert "bot_token" not in values + assert "bot_token" in secrets + assert values == {"allow_from": ["*"]} + + # --- (b) fallback derivation ------------------------------------------------- # -@pytest.mark.parametrize("name", ["discord", "websocket"]) +@pytest.mark.parametrize("name", ["discord", "telegram"]) def test_fallback_derives_types_and_secret_flags(name: str) -> None: """Adapters without hand-written hints derive metadata from default_config().""" config = _section_for(name) @@ -395,7 +574,13 @@ def test_discovered_adapter_surface_is_locked() -> None: @pytest.mark.parametrize("name", ADAPTER_NAMES) def test_split_never_leaks_secret_keys_in_values(name: str) -> None: - """No SECRET_KEY_RE key may appear in ``values`` for any adapter.""" + """A regex-matching key reaches ``values`` only via an audited hint. + + Hand-written hints are authoritative for the keys they cover (websocket's + ``token_ttl_s`` is a declared non-secret); every OTHER regex-matching key + — derived channels, unhinted extras — stays under the unconditional + :data:`SECRET_KEY_RE` fail-safe. + """ section = _section_for(name) if section is None: # Missing SDK (e.g. matrix today): hints degrade to empty and masking @@ -404,7 +589,13 @@ def test_split_never_leaks_secret_keys_in_values(name: str) -> None: section = {"webhook_secret": "dummy-secret-1234", "bot_token": "dummy-token"} values, secrets = split_values_secrets(name, section) - assert [key for key in values if SECRET_KEY_RE.search(key)] == [] + hand_written = { + hint["key"]: hint for hint in _config_meta.FIELD_HINTS.get(name, []) + } + for key in values: + if SECRET_KEY_RE.search(key): + assert key in hand_written, f"{name}.{key}: regex fail-safe bypassed" + assert hand_written[key]["secret"] is False assert set(values) | set(secrets) == set(section) assert not (set(values) & set(secrets)) diff --git a/agent/tests/test_channels_config_api.py b/agent/tests/test_channels_config_api.py index cbb98db3..582177ef 100644 --- a/agent/tests/test_channels_config_api.py +++ b/agent/tests/test_channels_config_api.py @@ -12,6 +12,7 @@ from __future__ import annotations import asyncio import json +import socket from pathlib import Path from typing import Any @@ -23,11 +24,16 @@ from fastapi.testclient import TestClient import api_server from src.api import channels_config_routes as routes from src.api import state as api_state +from src.channels import email_probe from src.channels.dingtalk import DINGTALK_AVAILABLE, DingTalkChannel CLIENT_ID = "ding-client-id-1234567890" STORED_SECRET = "stored-secret-abcdefghij" UNSAVED_SECRET = "unsaved-secret-xyz987654321" +EMAIL_IMAP_PASSWORD = "email-imap-password-987654321" +EMAIL_SMTP_PASSWORD = "email-smtp-password-123456789" +WS_TOKEN = "ws-token-secret-abcdefghij" +WS_ISSUE_SECRET = "ws-issue-secret-987654321" # Captured before any test monkeypatches httpx, so repeated injections in a # single test still wrap the real client (test_dingtalk_connection_test idiom). @@ -133,6 +139,27 @@ def _dingtalk_section(**overrides: Any) -> dict[str, Any]: return section +def _email_section(**overrides: Any) -> dict[str, Any]: + section: dict[str, Any] = { + "enabled": False, + "imap_host": "imap.example.com", + "imap_username": "bot@example.com", + "imap_password": EMAIL_IMAP_PASSWORD, + "smtp_host": "smtp.example.com", + "smtp_username": "sender@example.com", + "smtp_password": EMAIL_SMTP_PASSWORD, + } + section.update(overrides) + return section + + +def _free_port() -> int: + """Return a loopback port the OS just handed out (free at bind time).""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + def _inject_mock_transport( monkeypatch: pytest.MonkeyPatch, handler: Any ) -> list[httpx.Request]: @@ -809,7 +836,7 @@ def test_post_test_honors_pending_secret_clear(tmp_path: Path, monkeypatch) -> N def test_post_test_unsupported_channel_short_circuits(tmp_path: Path, monkeypatch) -> None: client, path = _client( - tmp_path, monkeypatch, channels={"email": {"enabled": False}} + tmp_path, monkeypatch, channels={"telegram": {"enabled": False}} ) before = path.read_bytes() @@ -818,7 +845,7 @@ def test_post_test_unsupported_channel_short_circuits(tmp_path: Path, monkeypatc requests = _inject_mock_transport(monkeypatch, handler) - response = client.post("/channels/email/test", json={}) + response = client.post("/channels/telegram/test", json={}) assert response.status_code == 200 body = response.json() @@ -879,3 +906,275 @@ def test_no_secret_value_in_any_response_body(tmp_path: Path, monkeypatch) -> No assert [r.status_code for r in responses] == [200, 422, 200, 200] for response in responses: assert STORED_SECRET not in response.text + + +# --------------------------------------------------------------------------- # +# Email + WebSocket: guided-channel parity (hot config backend support) +# --------------------------------------------------------------------------- # + + +def test_put_websocket_minimal_section_is_not_a_type_error( + tmp_path: Path, monkeypatch +) -> None: + """Regression: the ephemeral websocket build needs the gateway kwarg. + + ``WebSocketChannel.__init__`` requires a keyword-only ``gateway``, which + ``_build_ephemeral`` did not pass — so every PUT of a websocket section + died as a 422 ``TypeError`` validation envelope before anything else ran. + """ + port = _free_port() + client, path = _client(tmp_path, monkeypatch, channels={}) + + response = client.put( + "/channels/config/websocket", + json={"config": {"enabled": False, "host": "127.0.0.1", "port": port}}, + ) + + assert response.status_code != 422 + assert response.status_code == 200 + on_disk = json.loads(path.read_text(encoding="utf-8"))["channels"]["websocket"] + assert on_disk["host"] == "127.0.0.1" + assert on_disk["port"] == port + + +def test_post_websocket_test_returns_probe_code_not_unsupported( + tmp_path: Path, monkeypatch +) -> None: + client, path = _client(tmp_path, monkeypatch, channels={}) + before = path.read_bytes() + + response = client.post("/channels/websocket/test", json={}) + + assert response.status_code == 200 + body = response.json() + assert body["code"] in {"ok", "invalid_credentials", "network"} + assert body["code"] != "unsupported" + assert body["tested_saved_config"] is True + # The test endpoint never persists anything. + assert path.read_bytes() == before + + +def test_post_email_test_empty_config_reports_missing_credentials( + tmp_path: Path, monkeypatch +) -> None: + client, path = _client(tmp_path, monkeypatch, channels={}) + before = path.read_bytes() + + response = client.post("/channels/email/test", json={}) + + assert response.status_code == 200 + body = response.json() + assert body["ok"] is False + assert body["code"] == "invalid_credentials" + assert body["detail"] == ( + "missing credentials: imap_host, imap_username, imap_password, " + "smtp_host, smtp_username, smtp_password" + ) + assert body["sdk_available"] is True + assert path.read_bytes() == before + + +def test_get_email_and_websocket_entries_have_help_keys_and_masked_secrets( + tmp_path: Path, monkeypatch +) -> None: + client, _ = _client( + tmp_path, + monkeypatch, + channels={ + "email": _email_section(), + "websocket": { + "enabled": False, + "token": WS_TOKEN, + "token_issue_secret": WS_ISSUE_SECRET, + }, + }, + ) + + response = client.get("/channels/config") + + assert response.status_code == 200 + payload = response.json() + + email_entry = payload["channels"]["email"] + assert email_entry["supports_test"] is True + hints = {field["key"]: field for field in email_entry["fields"]} + assert "enabled" not in hints + assert ( + hints["imap_host"]["help_key"] == "settings.channels.fields.email.imap_host" + ) + assert hints["imap_password"]["secret"] is True + assert hints["imap_password"]["type"] == "password" + assert email_entry["values"]["imap_host"] == "imap.example.com" + assert "imap_password" not in email_entry["values"] + assert "smtp_password" not in email_entry["values"] + assert email_entry["secrets"]["imap_password"]["set"] is True + assert email_entry["secrets"]["imap_password"]["masked"].startswith("****") + assert email_entry["secrets"]["smtp_password"]["set"] is True + + ws_entry = payload["channels"]["websocket"] + assert ws_entry["supports_test"] is True + ws_hints = {field["key"]: field for field in ws_entry["fields"]} + assert "enabled" not in ws_hints + assert ws_hints["token"]["help_key"] == "settings.channels.fields.websocket.token" + assert ws_hints["token"]["secret"] is True + assert ws_hints["token_issue_secret"]["secret"] is True + # ssl_certfile/ssl_keyfile are filesystem paths, not secret material. + assert ws_hints["ssl_certfile"]["secret"] is False + assert ws_hints["ssl_keyfile"]["secret"] is False + assert "token" not in ws_entry["values"] + assert ws_entry["secrets"]["token"] == {"set": True, "masked": "****ghij"} + assert ws_entry["secrets"]["token_issue_secret"]["set"] is True + + for secret in ( + EMAIL_IMAP_PASSWORD, + EMAIL_SMTP_PASSWORD, + WS_TOKEN, + WS_ISSUE_SECRET, + ): + assert secret not in response.text + + +def test_put_email_enable_probe_failure_blocks_write( + tmp_path: Path, monkeypatch +) -> None: + client, path = _client(tmp_path, monkeypatch, channels={"email": _email_section()}) + before = path.read_bytes() + + async def failing_probe(config: Any) -> dict[str, Any]: + return { + "ok": False, + "code": "invalid_credentials", + "detail": "imap: login failed", + "sdk_available": True, + } + + monkeypatch.setattr(email_probe, "test_connection", failing_probe) + + response = client.put("/channels/config/email", json={"config": {"enabled": True}}) + + assert response.status_code == 422 + detail = response.json()["detail"] + assert detail["code"] == "invalid_credentials" + assert detail["fields"] == [] + assert detail["message"] == "imap: login failed" + # The security property: bad credentials never reach disk. + assert path.read_bytes() == before + assert EMAIL_IMAP_PASSWORD not in response.text + + +def test_put_email_enable_skip_verify_bypasses_probe( + tmp_path: Path, monkeypatch +) -> None: + client, path = _client(tmp_path, monkeypatch, channels={"email": _email_section()}) + + async def exploding_probe(config: Any) -> dict[str, Any]: # pragma: no cover + raise AssertionError("probe must not run with skip_verify") + + monkeypatch.setattr(email_probe, "test_connection", exploding_probe) + + response = client.put( + "/channels/config/email", + json={"config": {"enabled": True}, "skip_verify": True}, + ) + + assert response.status_code == 200 + on_disk = json.loads(path.read_text(encoding="utf-8"))["channels"]["email"] + assert on_disk["enabled"] is True + + +# --------------------------------------------------------------------------- # +# Hint-authoritative secret resolution (websocket token-shaped non-secrets) +# --------------------------------------------------------------------------- # + + +def _websocket_section(**overrides: Any) -> dict[str, Any]: + section: dict[str, Any] = { + "enabled": False, + "websocket_requires_token": True, + "token_ttl_s": 300, + "token_issue_path": "/issue", + "token": "s3cret-value", + } + section.update(overrides) + return section + + +def test_get_websocket_token_shaped_non_secrets_stay_in_values( + tmp_path: Path, monkeypatch +) -> None: + client, _ = _client( + tmp_path, monkeypatch, channels={"websocket": _websocket_section()} + ) + + response = client.get("/channels/config") + + assert response.status_code == 200 + entry = response.json()["channels"]["websocket"] + assert entry["values"]["websocket_requires_token"] is True + assert entry["values"]["token_ttl_s"] == 300 + assert entry["values"]["token_issue_path"] == "/issue" + assert "token" not in entry["values"] + assert entry["secrets"]["token"] == {"set": True, "masked": "****alue"} + assert "s3cret-value" not in response.text + + +def test_put_websocket_form_patch_preserves_requires_token( + tmp_path: Path, monkeypatch +) -> None: + """The exact security-flip regression: a form save must not write False. + + Before hint-authoritative secret resolution, ``websocket_requires_token`` + was regex-masked out of GET ``values``; the bool widget then seeded + ``Boolean(undefined) = false`` and buildPatch always includes bool fields, + so ANY save silently disabled the WS handshake token requirement. + """ + client, path = _client( + tmp_path, monkeypatch, channels={"websocket": _websocket_section()} + ) + patch: dict[str, Any] = { + "enabled": False, + "host": "127.0.0.1", + "port": _free_port(), + "unix_socket_path": "", + "path": "/", + "token_issue_path": "/issue", + "token_ttl_s": "600", + "websocket_requires_token": True, + "allow_from": ["*"], + "streaming": True, + "max_message_bytes": "37748736", + "ping_interval_s": "20", + "ping_timeout_s": "20", + "ssl_certfile": "", + "ssl_keyfile": "", + } + + response = client.put("/channels/config/websocket", json={"config": patch}) + + assert response.status_code == 200 + on_disk = json.loads(path.read_text(encoding="utf-8"))["channels"]["websocket"] + assert on_disk["websocket_requires_token"] is True + # The writer persists the form patch verbatim; the adapter coerces types. + assert on_disk["token_ttl_s"] == "600" + assert on_disk["token_issue_path"] == "/issue" + # A secret absent from the patch keeps its stored value. + assert on_disk["token"] == "s3cret-value" + assert "s3cret-value" not in response.text + + +def test_put_websocket_clears_non_secret_token_issue_path( + tmp_path: Path, monkeypatch +) -> None: + """A cleared non-secret text field persists as "" (no longer dropped).""" + client, path = _client( + tmp_path, monkeypatch, channels={"websocket": _websocket_section()} + ) + + response = client.put( + "/channels/config/websocket", json={"config": {"token_issue_path": ""}} + ) + + assert response.status_code == 200 + on_disk = json.loads(path.read_text(encoding="utf-8"))["channels"]["websocket"] + assert on_disk["token_issue_path"] == "" + assert on_disk["token"] == "s3cret-value" diff --git a/agent/tests/test_email_connection_test.py b/agent/tests/test_email_connection_test.py new file mode 100644 index 00000000..08331379 --- /dev/null +++ b/agent/tests/test_email_connection_test.py @@ -0,0 +1,761 @@ +"""Connection-test contract for the Email channel. + +Mirrors ``test_qq_connection_test.py``: Email implements the standalone +credential probe (IMAP login + mailbox select, then SMTP connect + login) +without ever starting the polling loop or sending a message. The contract +codes the frontend dispatches on are ``ok | invalid_credentials | network`` +plus an ``sdk_available`` flag (always True — the probe is stdlib-only), and +no password or username value may ever leak into the result or logs. All +``imaplib`` / ``smtplib`` transports are replaced with recording fakes, so no +test performs real network I/O. +""" + +from __future__ import annotations + +import asyncio +import imaplib +import json +import logging +import smtplib +import socket +import ssl +from email.message import EmailMessage +from typing import Any + +import pytest + +from src.channels.bus.queue import MessageBus +from src.channels.email import EmailChannel +from src.channels.utils import email_tls_context, send_imap_id + +IMAP_HOST = "imap.example.com" +SMTP_HOST = "smtp.example.com" +IMAP_USER = "bot@example.com" +IMAP_PASS = "imap-password-secret-1234" +SMTP_USER = "sender@example.com" +SMTP_PASS = "smtp-password-secret-5678" + +_CREDENTIAL_FIELDS = ( + "imap_host", + "imap_username", + "imap_password", + "smtp_host", + "smtp_username", + "smtp_password", +) + + +def _config(**overrides: Any) -> dict[str, Any]: + section: dict[str, Any] = { + "imap_host": IMAP_HOST, + "imap_username": IMAP_USER, + "imap_password": IMAP_PASS, + "smtp_host": SMTP_HOST, + "smtp_username": SMTP_USER, + "smtp_password": SMTP_PASS, + } + section.update(overrides) + return section + + +def _make_channel(**overrides: Any) -> EmailChannel: + """Build an Email channel that has NOT been started (no polling loop).""" + return EmailChannel(_config(**overrides), MessageBus()) + + +def _run(coro: Any) -> Any: + return asyncio.run(coro) + + +def _install_imap_fakes( + monkeypatch: pytest.MonkeyPatch, + *, + connect_error: BaseException | None = None, + login_error: BaseException | None = None, + select_result: tuple[str, list[Any]] = ("OK", [b"1"]), + select_error: BaseException | None = None, +) -> dict[str, Any]: + """Replace ``imaplib.IMAP4``/``IMAP4_SSL`` with recording fakes.""" + calls: dict[str, Any] = { + "constructed": [], + "ssl_contexts": [], + "logins": [], + "ids": [], + "selects": [], + "logouts": 0, + } + + class FakeIMAP4: + kind = "IMAP4" + + def __init__( + self, + host: str, + port: int, + timeout: float | None = None, + ssl_context: Any = None, + ) -> None: + calls["constructed"].append((self.kind, host, port, timeout)) + calls["ssl_contexts"].append(ssl_context) + if connect_error is not None: + raise connect_error + + def login(self, user: str, password: str) -> tuple[str, list[bytes]]: + calls["logins"].append((user, password)) + if login_error is not None: + raise login_error + return ("OK", [b"LOGIN completed"]) + + def xatom(self, name: str, *args: Any) -> tuple[str, list[Any]]: + calls["ids"].append((name, *args)) + return ("OK", [b"ID completed"]) + + def select(self, mailbox: str = "INBOX") -> tuple[str, list[Any]]: + calls["selects"].append(mailbox) + if select_error is not None: + raise select_error + return select_result + + def logout(self) -> tuple[str, list[bytes]]: + calls["logouts"] += 1 + return ("BYE", [b"bye"]) + + class FakeIMAP4_SSL(FakeIMAP4): + kind = "IMAP4_SSL" + + monkeypatch.setattr(imaplib, "IMAP4", FakeIMAP4) + monkeypatch.setattr(imaplib, "IMAP4_SSL", FakeIMAP4_SSL) + return calls + + +def _install_smtp_fakes( + monkeypatch: pytest.MonkeyPatch, + *, + connect_error: BaseException | None = None, + starttls_error: BaseException | None = None, + login_error: BaseException | None = None, +) -> dict[str, Any]: + """Replace ``smtplib.SMTP``/``SMTP_SSL`` with recording fakes.""" + calls: dict[str, Any] = { + "constructed": [], + "ssl_contexts": [], + "starttls": [], + "logins": [], + "sent": [], + "quits": 0, + } + + class FakeSMTP: + kind = "SMTP" + + def __init__( + self, + host: str, + port: int, + timeout: float | None = None, + ssl_context: Any = None, + ) -> None: + calls["constructed"].append((self.kind, host, port, timeout)) + calls["ssl_contexts"].append(ssl_context) + if connect_error is not None: + raise connect_error + + def __enter__(self) -> "FakeSMTP": + return self + + def __exit__(self, *exc_info: Any) -> None: + return None + + def send_message(self, msg: Any) -> dict[Any, Any]: + calls["sent"].append(msg) + return {} + + def starttls(self, context: Any = None) -> tuple[int, bytes]: + calls["starttls"].append(context is not None) + if starttls_error is not None: + raise starttls_error + return (220, b"Ready to start TLS") + + def login(self, user: str, password: str) -> tuple[int, bytes]: + calls["logins"].append((user, password)) + if login_error is not None: + raise login_error + return (235, b"Authentication successful") + + def quit(self) -> tuple[int, bytes]: + calls["quits"] += 1 + return (221, b"Bye") + + class FakeSMTP_SSL(FakeSMTP): + kind = "SMTP_SSL" + + monkeypatch.setattr(smtplib, "SMTP", FakeSMTP) + monkeypatch.setattr(smtplib, "SMTP_SSL", FakeSMTP_SSL) + return calls + + +def _install_forbidden_fakes(monkeypatch: pytest.MonkeyPatch) -> None: + """Fail the test if any IMAP/SMTP transport is constructed at all.""" + + def forbidden(*args: Any, **kwargs: Any) -> Any: + raise AssertionError("no IMAP/SMTP connection may happen without credentials") + + monkeypatch.setattr(imaplib, "IMAP4", forbidden) + monkeypatch.setattr(imaplib, "IMAP4_SSL", forbidden) + monkeypatch.setattr(smtplib, "SMTP", forbidden) + monkeypatch.setattr(smtplib, "SMTP_SSL", forbidden) + + +# --------------------------------------------------------------------------- # +# Success +# --------------------------------------------------------------------------- # + + +def test_success_probes_imap_then_smtp(monkeypatch: pytest.MonkeyPatch) -> None: + assert EmailChannel.supports_connection_test is True + imap_calls = _install_imap_fakes(monkeypatch) + smtp_calls = _install_smtp_fakes(monkeypatch) + + result = _run(_make_channel().test_connection()) + + assert result["ok"] is True + assert result["code"] == "ok" + # Email is stdlib-only: sdk_available is unconditionally True. + assert result["sdk_available"] is True + assert imap_calls["constructed"] == [("IMAP4_SSL", IMAP_HOST, 993, 10)] + assert imap_calls["logins"] == [(IMAP_USER, IMAP_PASS)] + assert imap_calls["selects"] == ["INBOX"] + assert imap_calls["logouts"] == 1 + # Defaults: smtp_use_ssl=False, smtp_use_tls=True → SMTP + starttls(context). + assert smtp_calls["constructed"] == [("SMTP", SMTP_HOST, 587, 10)] + assert smtp_calls["starttls"] == [True] + assert smtp_calls["logins"] == [(SMTP_USER, SMTP_PASS)] + assert smtp_calls["quits"] == 1 + serialized = json.dumps(result) + assert IMAP_PASS not in serialized + assert SMTP_PASS not in serialized + + +def test_custom_mailbox_is_selected(monkeypatch: pytest.MonkeyPatch) -> None: + imap_calls = _install_imap_fakes(monkeypatch) + _install_smtp_fakes(monkeypatch) + + result = _run(_make_channel(imap_mailbox="Archive").test_connection()) + + assert result["ok"] is True + assert imap_calls["selects"] == ["Archive"] + + +# --------------------------------------------------------------------------- # +# Missing-credentials short circuit +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("field", _CREDENTIAL_FIELDS) +def test_missing_credential_short_circuits_without_io( + monkeypatch: pytest.MonkeyPatch, field: str +) -> None: + _install_forbidden_fakes(monkeypatch) + + result = _run(_make_channel(**{field: ""}).test_connection()) + + assert result["ok"] is False + assert result["code"] == "invalid_credentials" + assert result["detail"] == f"missing credentials: {field}" + assert result["sdk_available"] is True + + +def test_multiple_missing_credentials_are_all_named( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_forbidden_fakes(monkeypatch) + + result = _run(_make_channel(imap_host="", smtp_password="").test_connection()) + + assert result["ok"] is False + assert result["code"] == "invalid_credentials" + assert result["detail"] == "missing credentials: imap_host, smtp_password" + + +# --------------------------------------------------------------------------- # +# IMAP classification +# --------------------------------------------------------------------------- # + + +def test_imap_login_rejection_reports_invalid_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_imap_fakes( + monkeypatch, login_error=imaplib.IMAP4.error("LOGIN failed: bad password") + ) + smtp_calls = _install_smtp_fakes(monkeypatch) + + result = _run(_make_channel().test_connection()) + + assert result["ok"] is False + assert result["code"] == "invalid_credentials" + assert result["detail"].startswith("imap: ") + assert result["sdk_available"] is True + # The SMTP half never runs after an IMAP failure. + assert smtp_calls["constructed"] == [] + + +def test_imap_abort_on_login_reports_invalid_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_imap_fakes( + monkeypatch, login_error=imaplib.IMAP4.abort("connection aborted mid-login") + ) + _install_smtp_fakes(monkeypatch) + + result = _run(_make_channel().test_connection()) + + assert result["ok"] is False + assert result["code"] == "invalid_credentials" + + +@pytest.mark.parametrize( + "connect_error", + [OSError("connection refused"), socket.timeout("timed out")], +) +def test_imap_connect_failure_reports_network( + monkeypatch: pytest.MonkeyPatch, connect_error: BaseException +) -> None: + _install_imap_fakes(monkeypatch, connect_error=connect_error) + _install_smtp_fakes(monkeypatch) + + result = _run(_make_channel().test_connection()) + + assert result["ok"] is False + assert result["code"] == "network" + assert result["detail"].startswith("imap: ") + + +def test_imap_select_non_ok_names_mailbox(monkeypatch: pytest.MonkeyPatch) -> None: + imap_calls = _install_imap_fakes( + monkeypatch, select_result=("NO", [b"Mailbox does not exist"]) + ) + _install_smtp_fakes(monkeypatch) + + result = _run(_make_channel(imap_mailbox="Archive").test_connection()) + + assert result["ok"] is False + assert result["code"] == "invalid_credentials" + assert "Archive" in result["detail"] + assert "select failed" in result["detail"] + # The connection is still logged out after a select failure. + assert imap_calls["logouts"] == 1 + + +def test_imap_select_exception_names_mailbox(monkeypatch: pytest.MonkeyPatch) -> None: + _install_imap_fakes( + monkeypatch, select_error=imaplib.IMAP4.error("SELECT exploded") + ) + _install_smtp_fakes(monkeypatch) + + result = _run(_make_channel(imap_mailbox="Archive").test_connection()) + + assert result["ok"] is False + assert result["code"] == "invalid_credentials" + assert "Archive" in result["detail"] + assert "SELECT exploded" in result["detail"] + + +def test_plain_imap_transport_when_ssl_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + imap_calls = _install_imap_fakes(monkeypatch) + _install_smtp_fakes(monkeypatch) + + result = _run(_make_channel(imap_use_ssl=False).test_connection()) + + assert result["ok"] is True + assert imap_calls["constructed"] == [("IMAP4", IMAP_HOST, 993, 10)] + + +# --------------------------------------------------------------------------- # +# SMTP classification +# --------------------------------------------------------------------------- # + + +def test_smtp_auth_error_reports_invalid_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_imap_fakes(monkeypatch) + _install_smtp_fakes( + monkeypatch, + login_error=smtplib.SMTPAuthenticationError(535, b"authentication failed"), + ) + + result = _run(_make_channel().test_connection()) + + assert result["ok"] is False + assert result["code"] == "invalid_credentials" + assert result["detail"].startswith("smtp: ") + assert "535" in result["detail"] + + +def test_smtp_connect_error_reports_network(monkeypatch: pytest.MonkeyPatch) -> None: + _install_imap_fakes(monkeypatch) + _install_smtp_fakes( + monkeypatch, connect_error=smtplib.SMTPConnectError(421, b"try later") + ) + + result = _run(_make_channel().test_connection()) + + assert result["ok"] is False + assert result["code"] == "network" + assert result["detail"].startswith("smtp: ") + + +def test_smtp_starttls_failure_reports_network(monkeypatch: pytest.MonkeyPatch) -> None: + _install_imap_fakes(monkeypatch) + _install_smtp_fakes( + monkeypatch, + starttls_error=smtplib.SMTPResponseException(502, b"STARTTLS not supported"), + ) + + result = _run(_make_channel().test_connection()) + + assert result["ok"] is False + assert result["code"] == "network" + + +def test_smtp_login_disconnect_reports_network(monkeypatch: pytest.MonkeyPatch) -> None: + _install_imap_fakes(monkeypatch) + _install_smtp_fakes( + monkeypatch, + login_error=smtplib.SMTPServerDisconnected("connection lost during login"), + ) + + result = _run(_make_channel().test_connection()) + + assert result["ok"] is False + assert result["code"] == "network" + + +def test_smtp_ssl_transport_skips_starttls(monkeypatch: pytest.MonkeyPatch) -> None: + _install_imap_fakes(monkeypatch) + smtp_calls = _install_smtp_fakes(monkeypatch) + + result = _run(_make_channel(smtp_use_ssl=True, smtp_port=465).test_connection()) + + assert result["ok"] is True + assert smtp_calls["constructed"] == [("SMTP_SSL", SMTP_HOST, 465, 10)] + assert smtp_calls["starttls"] == [] + + +# --------------------------------------------------------------------------- # +# TLS certificate verification (verify_tls) +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("verify", [True, False]) +def test_email_tls_context_verify_modes(verify: bool) -> None: + ctx = email_tls_context(verify) + + if verify: + assert ctx.verify_mode == ssl.CERT_REQUIRED + assert ctx.check_hostname is True + else: + assert ctx.verify_mode == ssl.CERT_NONE + assert ctx.check_hostname is False + + +@pytest.mark.parametrize("verify_tls", [True, False]) +def test_probe_imap_ssl_context_follows_verify_tls( + monkeypatch: pytest.MonkeyPatch, verify_tls: bool +) -> None: + """The probe must never send the IMAP password over an unverified socket + unless the operator explicitly opted out with ``verify_tls=False``.""" + imap_calls = _install_imap_fakes(monkeypatch) + _install_smtp_fakes(monkeypatch) + + result = _run(_make_channel(verify_tls=verify_tls).test_connection()) + + assert result["ok"] is True + ctx = imap_calls["ssl_contexts"][0] + assert isinstance(ctx, ssl.SSLContext) + if verify_tls: + assert ctx.verify_mode == ssl.CERT_REQUIRED + assert ctx.check_hostname is True + else: + assert ctx.verify_mode == ssl.CERT_NONE + assert ctx.check_hostname is False + + +@pytest.mark.parametrize("verify_tls", [True, False]) +def test_probe_smtp_ssl_context_follows_verify_tls( + monkeypatch: pytest.MonkeyPatch, verify_tls: bool +) -> None: + imap_calls = _install_imap_fakes(monkeypatch) + smtp_calls = _install_smtp_fakes(monkeypatch) + + result = _run( + _make_channel( + smtp_use_ssl=True, smtp_port=465, verify_tls=verify_tls + ).test_connection() + ) + + assert result["ok"] is True + ctx = smtp_calls["ssl_contexts"][0] + assert isinstance(ctx, ssl.SSLContext) + if verify_tls: + assert ctx.verify_mode == ssl.CERT_REQUIRED + assert ctx.check_hostname is True + else: + assert ctx.verify_mode == ssl.CERT_NONE + assert ctx.check_hostname is False + # The plain IMAP4_SSL probe half shares the same verify_tls switch. + assert isinstance(imap_calls["ssl_contexts"][0], ssl.SSLContext) + + +def test_probe_plain_transports_pass_no_ssl_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Non-SSL transports take no context; STARTTLS keeps its own verifying one.""" + imap_calls = _install_imap_fakes(monkeypatch) + smtp_calls = _install_smtp_fakes(monkeypatch) + + result = _run( + _make_channel(imap_use_ssl=False, smtp_use_ssl=False).test_connection() + ) + + assert result["ok"] is True + assert imap_calls["ssl_contexts"] == [None] + assert smtp_calls["ssl_contexts"] == [None] + assert smtp_calls["starttls"] == [True] + + +@pytest.mark.parametrize("verify_tls", [True, False]) +def test_adapter_imap_ssl_context_follows_verify_tls( + monkeypatch: pytest.MonkeyPatch, verify_tls: bool +) -> None: + """The adapter's polling connection uses the same helper as the probe.""" + imap_calls = _install_imap_fakes(monkeypatch) + channel = _make_channel(verify_tls=verify_tls) + + client = channel._open_imap_client("INBOX") + + assert client is not None + ctx = imap_calls["ssl_contexts"][0] + expected = ssl.CERT_REQUIRED if verify_tls else ssl.CERT_NONE + assert ctx.verify_mode == expected + assert ctx.check_hostname is verify_tls + + +@pytest.mark.parametrize("verify_tls", [True, False]) +def test_adapter_smtp_ssl_context_follows_verify_tls( + monkeypatch: pytest.MonkeyPatch, verify_tls: bool +) -> None: + smtp_calls = _install_smtp_fakes(monkeypatch) + channel = _make_channel(smtp_use_ssl=True, smtp_port=465, verify_tls=verify_tls) + + channel._smtp_send(EmailMessage()) + + ctx = smtp_calls["ssl_contexts"][0] + expected = ssl.CERT_REQUIRED if verify_tls else ssl.CERT_NONE + assert ctx.verify_mode == expected + assert ctx.check_hostname is verify_tls + assert len(smtp_calls["sent"]) == 1 + + +def test_verify_tls_defaults_to_true() -> None: + """Secure by default: certificate verification is on unless opted out.""" + channel = _make_channel() + + assert channel.config.verify_tls is True + + +# --------------------------------------------------------------------------- # +# Secret hygiene +# --------------------------------------------------------------------------- # + + +def test_server_error_echoing_credentials_is_scrubbed( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + _install_imap_fakes( + monkeypatch, + login_error=imaplib.IMAP4.error( + f"LOGIN failed: user {IMAP_USER} password {IMAP_PASS} rejected" + ), + ) + _install_smtp_fakes(monkeypatch) + + with caplog.at_level(logging.DEBUG): + result = _run(_make_channel().test_connection()) + + assert result["code"] == "invalid_credentials" + serialized = json.dumps(result) + assert IMAP_PASS not in serialized + assert IMAP_USER not in serialized + assert "***" in result["detail"] + assert IMAP_PASS not in caplog.text + assert IMAP_USER not in caplog.text + + +def test_smtp_error_echoing_credentials_is_scrubbed( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + _install_imap_fakes(monkeypatch) + _install_smtp_fakes( + monkeypatch, + login_error=smtplib.SMTPAuthenticationError( + 535, f"bad login for {SMTP_USER} with {SMTP_PASS}".encode() + ), + ) + + with caplog.at_level(logging.DEBUG): + result = _run(_make_channel().test_connection()) + + assert result["code"] == "invalid_credentials" + serialized = json.dumps(result) + assert SMTP_PASS not in serialized + assert SMTP_PASS not in caplog.text + + +def test_detail_is_bounded_to_200_chars(monkeypatch: pytest.MonkeyPatch) -> None: + _install_imap_fakes(monkeypatch, login_error=imaplib.IMAP4.error("x" * 500)) + _install_smtp_fakes(monkeypatch) + + result = _run(_make_channel().test_connection()) + + assert result["code"] == "invalid_credentials" + assert len(result["detail"]) <= 200 + + +def test_result_envelope_is_json_serializable(monkeypatch: pytest.MonkeyPatch) -> None: + _install_imap_fakes(monkeypatch) + _install_smtp_fakes(monkeypatch) + + result = _run(_make_channel().test_connection()) + + assert json.loads(json.dumps(result)) == result + + +def test_probe_module_never_imports_the_adapter_at_runtime() -> None: + """The probe keeps the adapter import TYPE_CHECKING-only (no cycle).""" + import inspect as _inspect + + from src.channels import email_probe + + source = _inspect.getsource(email_probe) + assert "from src.channels.email import EmailConfig" in source + assert "if TYPE_CHECKING:" in source + + +# --------------------------------------------------------------------------- # +# start()/stop() interaction with destructive post-actions +# --------------------------------------------------------------------------- # + + +def test_stop_during_fetch_delivers_batch_but_skips_post_actions() -> None: + """stop() is flag-only; a fetch already in flight still delivers. + + The already-fetched batch must reach the bus (those messages were marked + seen during fetch — dropping them would lose mail), but the destructive + delete/move post-actions of the stale config must not run after stop. + """ + channel = _make_channel(consent_granted=True, post_action="delete") + delivered: list[str] = [] + applied: list[list[str]] = [] + + def fake_fetch() -> tuple[list[dict[str, Any]], set[str]]: + channel._running = False + item = { + "sender": "user@example.com", + "subject": "hello", + "message_id": "", + "content": "in-flight body", + "metadata": {"uid": "42"}, + "media": [], + } + return [item], set() + + async def fake_handle(**kwargs: Any) -> None: + delivered.append(kwargs["content"]) + + def fake_post_actions(uids: list[str]) -> None: + applied.append(uids) + + channel._fetch_new_messages = fake_fetch # type: ignore[method-assign] + channel._handle_message = fake_handle # type: ignore[method-assign] + channel._apply_post_actions_batch = fake_post_actions # type: ignore[method-assign] + + _run(channel.start()) + + assert delivered == ["in-flight body"] + assert applied == [] + + +# --------------------------------------------------------------------------- # +# IMAP ID (RFC 2971) — NetEase "Unsafe Login" interop +# --------------------------------------------------------------------------- # + + +def test_send_imap_id_sends_static_identification() -> None: + sent: list[tuple[Any, ...]] = [] + + class _Client: + def xatom(self, name: str, *args: Any) -> tuple[str, list[bytes]]: + sent.append((name, *args)) + return ("OK", [b"ID completed"]) + + send_imap_id(_Client()) + + assert len(sent) == 1 + name, payload = sent[0] + assert name == "ID" + assert "vibe-trading" in payload + + +def test_send_imap_id_swallows_errors() -> None: + class _Broken: + def xatom(self, *args: Any) -> Any: + raise OSError("connection reset") + + send_imap_id(_Broken()) + + +def test_send_imap_id_tolerates_client_without_xatom() -> None: + send_imap_id(object()) + + +def test_probe_sends_imap_id_before_select(monkeypatch: pytest.MonkeyPatch) -> None: + """NetEase regression: ID must be sent between LOGIN and SELECT.""" + order: list[str] = [] + + class FakeIMAP4_SSL: + def __init__( + self, + host: str, + port: int, + timeout: float | None = None, + ssl_context: Any = None, + ) -> None: + order.append("connect") + + def login(self, user: str, password: str) -> tuple[str, list[bytes]]: + order.append("login") + return ("OK", [b"LOGIN completed"]) + + def xatom(self, name: str, *args: Any) -> tuple[str, list[Any]]: + order.append(f"id:{name}") + return ("OK", [b"ID completed"]) + + def select(self, mailbox: str = "INBOX") -> tuple[str, list[Any]]: + order.append("select") + return ("OK", [b"3"]) + + def logout(self) -> tuple[str, list[bytes]]: + order.append("logout") + return ("BYE", [b"bye"]) + + monkeypatch.setattr(imaplib, "IMAP4_SSL", FakeIMAP4_SSL) + _install_smtp_fakes(monkeypatch) + + result = _run(_make_channel(imap_use_ssl=True).test_connection()) + + assert result["ok"] is True + assert order[:4] == ["connect", "login", "id:ID", "select"] diff --git a/agent/tests/test_security_auth_api.py b/agent/tests/test_security_auth_api.py index 4b8c1576..883eed1a 100644 --- a/agent/tests/test_security_auth_api.py +++ b/agent/tests/test_security_auth_api.py @@ -308,6 +308,121 @@ def test_local_dev_can_write_llm_settings_when_api_key_unset( assert "OPENAI_BASE_URL=https://api.openai.com/v1" in env_path.read_text(encoding="utf-8") +# ============================================================================ +# PUT /settings/llm: cross-site browser guard on require_settings_write_auth +# ============================================================================ + + +def test_settings_write_rejects_cross_site_sec_fetch_site( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + """A cross-site browser PUT must not reach the settings-write logic.""" + env_path = tmp_path / ".env" + monkeypatch.setattr(api_server, "ENV_PATH", env_path) + + response = _local_client().put( + "/settings/llm", + headers={"Host": "127.0.0.1:8899", "Sec-Fetch-Site": "cross-site"}, + json=_llm_settings_payload("https://attacker.example/openai-compatible/v1"), + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "Cross-site request denied" + assert not env_path.exists() + + +def test_settings_write_rejects_cross_site_origin( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + """An Origin that is neither loopback nor the request host is cross-site.""" + env_path = tmp_path / ".env" + monkeypatch.setattr(api_server, "ENV_PATH", env_path) + + response = _local_client().put( + "/settings/llm", + headers={"Host": "127.0.0.1:8899", "Origin": "https://attacker.example"}, + json=_llm_settings_payload("https://attacker.example/openai-compatible/v1"), + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "Cross-site request denied" + assert not env_path.exists() + + +def test_settings_write_same_origin_browser_passes_cross_site_guard( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + """A same-origin browser PUT clears the guard and reaches the write path.""" + env_path = tmp_path / ".env" + env_path.write_text("", encoding="utf-8") + monkeypatch.setattr(api_server, "ENV_PATH", env_path) + + response = _local_client().put( + "/settings/llm", + headers={ + "Host": "127.0.0.1:8899", + "Origin": "http://127.0.0.1:8899", + "Sec-Fetch-Site": "same-origin", + }, + json=_llm_settings_payload("https://api.openai.com/v1"), + ) + + assert response.status_code == 200 + assert "OPENAI_BASE_URL=https://api.openai.com/v1" in env_path.read_text(encoding="utf-8") + + +def test_settings_write_remote_same_origin_passes_guard_and_hits_auth_layer( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + """A host-matching Origin passes the guard; the later auth layer still decides.""" + env_path = tmp_path / ".env" + monkeypatch.setattr(api_server, "ENV_PATH", env_path) + client = TestClient( + api_server.app, + base_url="http://192.168.1.10:8899", + client=("192.168.1.20", 50000), + ) + + response = client.put( + "/settings/llm", + headers={ + "Host": "192.168.1.10:8899", + "Origin": "http://192.168.1.10:8899", + "Sec-Fetch-Site": "same-origin", + }, + json=_llm_settings_payload("https://api.openai.com/v1"), + ) + + assert response.status_code == 403 + assert ( + response.json()["detail"] + == "Settings writes require API_AUTH_KEY or a local loopback client" + ) + assert not env_path.exists() + + +def test_settings_write_without_browser_headers_passes_guard( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + """A non-browser client (CLI/curl) sends no Origin and must pass the guard.""" + env_path = tmp_path / ".env" + env_path.write_text("", encoding="utf-8") + monkeypatch.setattr(api_server, "ENV_PATH", env_path) + + response = _local_client().put( + "/settings/llm", + json=_llm_settings_payload("https://api.openai.com/v1"), + ) + + assert response.status_code == 200 + assert "OPENAI_BASE_URL=https://api.openai.com/v1" in env_path.read_text(encoding="utf-8") + + def test_loopback_rejects_rebound_host_before_auth_bypass( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/agent/tests/test_websocket_connection_test.py b/agent/tests/test_websocket_connection_test.py new file mode 100644 index 00000000..b47e7c3d --- /dev/null +++ b/agent/tests/test_websocket_connection_test.py @@ -0,0 +1,215 @@ +"""Connection-test contract for the WebSocket channel. + +WebSocket is a *server* channel: there are no remote credentials, so the +standalone probe validates local secret material (SSL cert/key) and address +availability (read-only TCP bind). The contract codes are +``ok | invalid_credentials | network`` plus an ``sdk_available`` flag. All +checks run against loopback only — no real network I/O — and the probe must +never write to the filesystem (no mkdir, no unlink of a stale unix socket). +""" + +from __future__ import annotations + +import asyncio +import json +import socket +from pathlib import Path +from typing import Any + +from src.channels.bus.queue import MessageBus +from src.channels.websocket import WebSocketChannel +from src.channelsui.gateway_services import build_gateway_services + +WS_TOKEN = "ws-token-secret-1234567890" + + +def _free_port() -> int: + """Return a loopback port the OS just handed out (free at bind time).""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _listening_socket(port: int) -> socket.socket: + """Hold *port* with a real listening socket (accepts TCP handshakes).""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", port)) + sock.listen(1) + return sock + + +def _bound_socket(port: int) -> socket.socket: + """Hold *port* bound but NOT listening (connect attempts are refused).""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", port)) + return sock + + +def _make_channel(**overrides: Any) -> WebSocketChannel: + config: dict[str, Any] = {"host": "127.0.0.1", "port": _free_port()} + config.update(overrides) + return WebSocketChannel(config, MessageBus(), gateway=build_gateway_services()) + + +def _run(coro: Any) -> Any: + return asyncio.run(coro) + + +# --------------------------------------------------------------------------- # +# Success +# --------------------------------------------------------------------------- # + + +def test_success_on_free_loopback_port() -> None: + assert WebSocketChannel.supports_connection_test is True + + result = _run(_make_channel().test_connection()) + + assert result["ok"] is True + assert result["code"] == "ok" + # websockets is a core dependency and the adapter module imported it. + assert result["sdk_available"] is True + assert "detail" not in result + + +# --------------------------------------------------------------------------- # +# SSL material +# --------------------------------------------------------------------------- # + + +def test_ssl_certfile_missing_reports_invalid_credentials(tmp_path: Path) -> None: + channel = _make_channel( + ssl_certfile=str(tmp_path / "missing.pem"), + ssl_keyfile=str(tmp_path / "missing.key"), + ) + + result = _run(channel.test_connection()) + + assert result["ok"] is False + assert result["code"] == "invalid_credentials" + assert result["detail"].startswith("ssl: ") + + +def test_ssl_garbage_material_reports_invalid_credentials(tmp_path: Path) -> None: + cert = tmp_path / "cert.pem" + key = tmp_path / "key.pem" + cert.write_bytes(b"not a cert") + key.write_bytes(b"not a key") + channel = _make_channel(ssl_certfile=str(cert), ssl_keyfile=str(key)) + + result = _run(channel.test_connection()) + + assert result["ok"] is False + assert result["code"] == "invalid_credentials" + assert result["detail"].startswith("ssl: ") + + +def test_ssl_half_configured_reports_both_or_neither(tmp_path: Path) -> None: + cert = tmp_path / "cert.pem" + cert.write_bytes(b"not a cert") + channel = _make_channel(ssl_certfile=str(cert), ssl_keyfile="") + + result = _run(channel.test_connection()) + + assert result["ok"] is False + assert result["code"] == "invalid_credentials" + assert result["detail"] == ( + "ssl: ssl_certfile and ssl_keyfile must both be set for WSS, " + "or both left empty" + ) + + +def test_ssl_failure_detail_never_carries_token(tmp_path: Path) -> None: + channel = _make_channel( + token=WS_TOKEN, + ssl_certfile=str(tmp_path / "missing.pem"), + ssl_keyfile=str(tmp_path / "missing.key"), + ) + + result = _run(channel.test_connection()) + + assert result["ok"] is False + assert WS_TOKEN not in json.dumps(result) + + +# --------------------------------------------------------------------------- # +# TCP bind / EADDRINUSE semantics +# --------------------------------------------------------------------------- # + + +def test_address_in_use_by_live_listener_reports_ok() -> None: + """The core hot-config scenario: Test clicked while the channel runs.""" + port = _free_port() + listener = _listening_socket(port) + try: + result = _run(_make_channel(port=port).test_connection()) + finally: + listener.close() + + assert result["ok"] is True + assert result["code"] == "ok" + assert "already in use" in result["detail"] + + +def test_address_in_use_without_listener_reports_network() -> None: + port = _free_port() + holder = _bound_socket(port) + try: + result = _run(_make_channel(port=port).test_connection()) + finally: + holder.close() + + assert result["ok"] is False + assert result["code"] == "network" + assert "not accepting connections" in result["detail"] + + +def test_unresolvable_host_reports_network() -> None: + channel = _make_channel(host="no-such-host.invalid") + + result = _run(channel.test_connection()) + + assert result["ok"] is False + assert result["code"] == "network" + assert result["detail"].startswith("bind: ") + + +# --------------------------------------------------------------------------- # +# Unix socket: TCP bind skipped, no filesystem side effects +# --------------------------------------------------------------------------- # + + +def test_unix_socket_path_skips_tcp_bind_entirely(tmp_path: Path) -> None: + """host/port are ignored by start() when a unix socket is configured.""" + port = _free_port() + listener = _listening_socket(port) + try: + channel = _make_channel( + unix_socket_path=str(tmp_path / "ws.sock"), host="127.0.0.1", port=port + ) + result = _run(channel.test_connection()) + finally: + listener.close() + + # The occupied port must not even be probed: ok with no bind note. + assert result["ok"] is True + assert result["code"] == "ok" + assert "detail" not in result + + +def test_probe_creates_nothing_on_the_filesystem(tmp_path: Path) -> None: + nested = tmp_path / "nested" + socket_path = nested / "ws.sock" + channel = _make_channel(unix_socket_path=str(socket_path)) + + result = _run(channel.test_connection()) + + assert result["ok"] is True + assert not nested.exists() + assert not socket_path.exists() + + +def test_result_envelope_is_json_serializable() -> None: + result = _run(_make_channel().test_connection()) + + assert json.loads(json.dumps(result)) == result diff --git a/frontend/src/components/settings/ChannelConfigPanel.tsx b/frontend/src/components/settings/ChannelConfigPanel.tsx index cd4cc6bd..5cf12664 100644 --- a/frontend/src/components/settings/ChannelConfigPanel.tsx +++ b/frontend/src/components/settings/ChannelConfigPanel.tsx @@ -110,6 +110,36 @@ const GUIDE_DEFS: Record< docsLabel: t("settings.channels.guides.qq.docsLabel"), }), }, + email: { + docsUrl: "https://vibetrading.wiki/docs/", + build: (t) => ({ + title: t("settings.channels.guides.email.title"), + intro: t("settings.channels.guides.email.intro"), + steps: [ + t("settings.channels.guides.email.step1"), + t("settings.channels.guides.email.step2"), + t("settings.channels.guides.email.step3"), + t("settings.channels.guides.email.step4"), + t("settings.channels.guides.email.step5"), + ], + docsLabel: t("settings.channels.guides.email.docsLabel"), + }), + }, + websocket: { + docsUrl: "https://vibetrading.wiki/docs/", + build: (t) => ({ + title: t("settings.channels.guides.websocket.title"), + intro: t("settings.channels.guides.websocket.intro"), + steps: [ + t("settings.channels.guides.websocket.step1"), + t("settings.channels.guides.websocket.step2"), + t("settings.channels.guides.websocket.step3"), + t("settings.channels.guides.websocket.step4"), + t("settings.channels.guides.websocket.step5"), + ], + docsLabel: t("settings.channels.guides.websocket.docsLabel"), + }), + }, }; export interface ChannelConfigPanelProps { @@ -185,6 +215,52 @@ export function ChannelConfigPanel({ "settings.channels.fields.qq.media_dir": { label: t("settings.channels.fields.qq.media_dir.label"), help: t("settings.channels.fields.qq.media_dir.help") }, "settings.channels.fields.qq.download_chunk_size": { label: t("settings.channels.fields.qq.download_chunk_size.label"), help: t("settings.channels.fields.qq.download_chunk_size.help") }, "settings.channels.fields.qq.download_max_bytes": { label: t("settings.channels.fields.qq.download_max_bytes.label"), help: t("settings.channels.fields.qq.download_max_bytes.help") }, + "settings.channels.fields.email.consent_granted": { label: t("settings.channels.fields.email.consent_granted.label"), help: t("settings.channels.fields.email.consent_granted.help") }, + "settings.channels.fields.email.imap_host": { label: t("settings.channels.fields.email.imap_host.label"), help: t("settings.channels.fields.email.imap_host.help") }, + "settings.channels.fields.email.imap_port": { label: t("settings.channels.fields.email.imap_port.label"), help: t("settings.channels.fields.email.imap_port.help") }, + "settings.channels.fields.email.imap_username": { label: t("settings.channels.fields.email.imap_username.label"), help: t("settings.channels.fields.email.imap_username.help") }, + "settings.channels.fields.email.imap_password": { label: t("settings.channels.fields.email.imap_password.label"), help: t("settings.channels.fields.email.imap_password.help") }, + "settings.channels.fields.email.imap_mailbox": { label: t("settings.channels.fields.email.imap_mailbox.label"), help: t("settings.channels.fields.email.imap_mailbox.help") }, + "settings.channels.fields.email.imap_use_ssl": { label: t("settings.channels.fields.email.imap_use_ssl.label"), help: t("settings.channels.fields.email.imap_use_ssl.help") }, + "settings.channels.fields.email.smtp_host": { label: t("settings.channels.fields.email.smtp_host.label"), help: t("settings.channels.fields.email.smtp_host.help") }, + "settings.channels.fields.email.smtp_port": { label: t("settings.channels.fields.email.smtp_port.label"), help: t("settings.channels.fields.email.smtp_port.help") }, + "settings.channels.fields.email.smtp_username": { label: t("settings.channels.fields.email.smtp_username.label"), help: t("settings.channels.fields.email.smtp_username.help") }, + "settings.channels.fields.email.smtp_password": { label: t("settings.channels.fields.email.smtp_password.label"), help: t("settings.channels.fields.email.smtp_password.help") }, + "settings.channels.fields.email.smtp_use_tls": { label: t("settings.channels.fields.email.smtp_use_tls.label"), help: t("settings.channels.fields.email.smtp_use_tls.help") }, + "settings.channels.fields.email.smtp_use_ssl": { label: t("settings.channels.fields.email.smtp_use_ssl.label"), help: t("settings.channels.fields.email.smtp_use_ssl.help") }, + "settings.channels.fields.email.verify_tls": { label: t("settings.channels.fields.email.verify_tls.label"), help: t("settings.channels.fields.email.verify_tls.help") }, + "settings.channels.fields.email.from_address": { label: t("settings.channels.fields.email.from_address.label"), help: t("settings.channels.fields.email.from_address.help") }, + "settings.channels.fields.email.auto_reply_enabled": { label: t("settings.channels.fields.email.auto_reply_enabled.label"), help: t("settings.channels.fields.email.auto_reply_enabled.help") }, + "settings.channels.fields.email.poll_interval_seconds": { label: t("settings.channels.fields.email.poll_interval_seconds.label"), help: t("settings.channels.fields.email.poll_interval_seconds.help") }, + "settings.channels.fields.email.mark_seen": { label: t("settings.channels.fields.email.mark_seen.label"), help: t("settings.channels.fields.email.mark_seen.help") }, + "settings.channels.fields.email.post_action": { label: t("settings.channels.fields.email.post_action.label"), help: t("settings.channels.fields.email.post_action.help") }, + "settings.channels.fields.email.post_action_move_mailbox": { label: t("settings.channels.fields.email.post_action_move_mailbox.label"), help: t("settings.channels.fields.email.post_action_move_mailbox.help") }, + "settings.channels.fields.email.post_action_expunge": { label: t("settings.channels.fields.email.post_action_expunge.label"), help: t("settings.channels.fields.email.post_action_expunge.help") }, + "settings.channels.fields.email.post_action_ignore_skipped": { label: t("settings.channels.fields.email.post_action_ignore_skipped.label"), help: t("settings.channels.fields.email.post_action_ignore_skipped.help") }, + "settings.channels.fields.email.max_body_chars": { label: t("settings.channels.fields.email.max_body_chars.label"), help: t("settings.channels.fields.email.max_body_chars.help") }, + "settings.channels.fields.email.subject_prefix": { label: t("settings.channels.fields.email.subject_prefix.label"), help: t("settings.channels.fields.email.subject_prefix.help") }, + "settings.channels.fields.email.allow_from": { label: t("settings.channels.fields.email.allow_from.label"), help: t("settings.channels.fields.email.allow_from.help") }, + "settings.channels.fields.email.verify_dkim": { label: t("settings.channels.fields.email.verify_dkim.label"), help: t("settings.channels.fields.email.verify_dkim.help") }, + "settings.channels.fields.email.verify_spf": { label: t("settings.channels.fields.email.verify_spf.label"), help: t("settings.channels.fields.email.verify_spf.help") }, + "settings.channels.fields.email.allowed_attachment_types": { label: t("settings.channels.fields.email.allowed_attachment_types.label"), help: t("settings.channels.fields.email.allowed_attachment_types.help") }, + "settings.channels.fields.email.max_attachment_size": { label: t("settings.channels.fields.email.max_attachment_size.label"), help: t("settings.channels.fields.email.max_attachment_size.help") }, + "settings.channels.fields.email.max_attachments_per_email": { label: t("settings.channels.fields.email.max_attachments_per_email.label"), help: t("settings.channels.fields.email.max_attachments_per_email.help") }, + "settings.channels.fields.websocket.host": { label: t("settings.channels.fields.websocket.host.label"), help: t("settings.channels.fields.websocket.host.help") }, + "settings.channels.fields.websocket.port": { label: t("settings.channels.fields.websocket.port.label"), help: t("settings.channels.fields.websocket.port.help") }, + "settings.channels.fields.websocket.unix_socket_path": { label: t("settings.channels.fields.websocket.unix_socket_path.label"), help: t("settings.channels.fields.websocket.unix_socket_path.help") }, + "settings.channels.fields.websocket.path": { label: t("settings.channels.fields.websocket.path.label"), help: t("settings.channels.fields.websocket.path.help") }, + "settings.channels.fields.websocket.token": { label: t("settings.channels.fields.websocket.token.label"), help: t("settings.channels.fields.websocket.token.help") }, + "settings.channels.fields.websocket.token_issue_path": { label: t("settings.channels.fields.websocket.token_issue_path.label"), help: t("settings.channels.fields.websocket.token_issue_path.help") }, + "settings.channels.fields.websocket.token_issue_secret": { label: t("settings.channels.fields.websocket.token_issue_secret.label"), help: t("settings.channels.fields.websocket.token_issue_secret.help") }, + "settings.channels.fields.websocket.token_ttl_s": { label: t("settings.channels.fields.websocket.token_ttl_s.label"), help: t("settings.channels.fields.websocket.token_ttl_s.help") }, + "settings.channels.fields.websocket.websocket_requires_token": { label: t("settings.channels.fields.websocket.websocket_requires_token.label"), help: t("settings.channels.fields.websocket.websocket_requires_token.help") }, + "settings.channels.fields.websocket.allow_from": { label: t("settings.channels.fields.websocket.allow_from.label"), help: t("settings.channels.fields.websocket.allow_from.help") }, + "settings.channels.fields.websocket.streaming": { label: t("settings.channels.fields.websocket.streaming.label"), help: t("settings.channels.fields.websocket.streaming.help") }, + "settings.channels.fields.websocket.max_message_bytes": { label: t("settings.channels.fields.websocket.max_message_bytes.label"), help: t("settings.channels.fields.websocket.max_message_bytes.help") }, + "settings.channels.fields.websocket.ping_interval_s": { label: t("settings.channels.fields.websocket.ping_interval_s.label"), help: t("settings.channels.fields.websocket.ping_interval_s.help") }, + "settings.channels.fields.websocket.ping_timeout_s": { label: t("settings.channels.fields.websocket.ping_timeout_s.label"), help: t("settings.channels.fields.websocket.ping_timeout_s.help") }, + "settings.channels.fields.websocket.ssl_certfile": { label: t("settings.channels.fields.websocket.ssl_certfile.label"), help: t("settings.channels.fields.websocket.ssl_certfile.help") }, + "settings.channels.fields.websocket.ssl_keyfile": { label: t("settings.channels.fields.websocket.ssl_keyfile.label"), help: t("settings.channels.fields.websocket.ssl_keyfile.help") }, }), [t], ); diff --git a/frontend/src/components/settings/__tests__/ChannelSettings.test.tsx b/frontend/src/components/settings/__tests__/ChannelSettings.test.tsx index 7b3ab5b3..c5155ef3 100644 --- a/frontend/src/components/settings/__tests__/ChannelSettings.test.tsx +++ b/frontend/src/components/settings/__tests__/ChannelSettings.test.tsx @@ -112,6 +112,138 @@ function qqEntry(overrides: Record = {}) { }; } +function emailEntry(overrides: Record = {}) { + return { + display_name: "Email", + available: true, + loaded: true, + install_hint: "", + error: "", + supports_test: true, + sdk_available: true, + fields: [ + { key: "consent_granted", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.email.consent_granted" }, + { key: "imap_host", type: "text", secret: false, required: true, help_key: "settings.channels.fields.email.imap_host" }, + { key: "imap_port", type: "text", secret: false, required: false, help_key: "settings.channels.fields.email.imap_port" }, + { key: "imap_username", type: "text", secret: false, required: true, help_key: "settings.channels.fields.email.imap_username" }, + { key: "imap_password", type: "password", secret: true, required: true, help_key: "settings.channels.fields.email.imap_password" }, + { key: "imap_mailbox", type: "text", secret: false, required: false, help_key: "settings.channels.fields.email.imap_mailbox" }, + { key: "imap_use_ssl", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.email.imap_use_ssl" }, + { key: "smtp_host", type: "text", secret: false, required: true, help_key: "settings.channels.fields.email.smtp_host" }, + { key: "smtp_port", type: "text", secret: false, required: false, help_key: "settings.channels.fields.email.smtp_port" }, + { key: "smtp_username", type: "text", secret: false, required: true, help_key: "settings.channels.fields.email.smtp_username" }, + { key: "smtp_password", type: "password", secret: true, required: true, help_key: "settings.channels.fields.email.smtp_password" }, + { key: "smtp_use_tls", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.email.smtp_use_tls" }, + { key: "smtp_use_ssl", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.email.smtp_use_ssl" }, + { key: "verify_tls", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.email.verify_tls" }, + { key: "from_address", type: "text", secret: false, required: false, help_key: "settings.channels.fields.email.from_address" }, + { key: "auto_reply_enabled", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.email.auto_reply_enabled" }, + { key: "poll_interval_seconds", type: "text", secret: false, required: false, help_key: "settings.channels.fields.email.poll_interval_seconds" }, + { key: "mark_seen", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.email.mark_seen" }, + { key: "post_action", type: "text", secret: false, required: false, help_key: "settings.channels.fields.email.post_action" }, + { key: "post_action_move_mailbox", type: "text", secret: false, required: false, help_key: "settings.channels.fields.email.post_action_move_mailbox" }, + { key: "post_action_expunge", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.email.post_action_expunge" }, + { key: "post_action_ignore_skipped", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.email.post_action_ignore_skipped" }, + { key: "max_body_chars", type: "text", secret: false, required: false, help_key: "settings.channels.fields.email.max_body_chars" }, + { key: "subject_prefix", type: "text", secret: false, required: false, help_key: "settings.channels.fields.email.subject_prefix" }, + { key: "allow_from", type: "list", secret: false, required: false, help_key: "settings.channels.fields.email.allow_from" }, + { key: "verify_dkim", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.email.verify_dkim" }, + { key: "verify_spf", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.email.verify_spf" }, + { key: "allowed_attachment_types", type: "list", secret: false, required: false, help_key: "settings.channels.fields.email.allowed_attachment_types" }, + { key: "max_attachment_size", type: "text", secret: false, required: false, help_key: "settings.channels.fields.email.max_attachment_size" }, + { key: "max_attachments_per_email", type: "text", secret: false, required: false, help_key: "settings.channels.fields.email.max_attachments_per_email" }, + ], + values: { + enabled: false, + consent_granted: false, + imap_host: "imap.example.com", + imap_port: 993, + imap_username: "bot@example.com", + imap_mailbox: "INBOX", + imap_use_ssl: true, + smtp_host: "smtp.example.com", + smtp_port: 587, + smtp_username: "bot@example.com", + smtp_use_tls: true, + smtp_use_ssl: false, + verify_tls: true, + from_address: "", + auto_reply_enabled: true, + poll_interval_seconds: 30, + mark_seen: true, + post_action: "", + post_action_move_mailbox: "", + post_action_expunge: false, + post_action_ignore_skipped: true, + max_body_chars: 12000, + subject_prefix: "Re: ", + allow_from: [], + verify_dkim: true, + verify_spf: true, + allowed_attachment_types: [], + max_attachment_size: 2000000, + max_attachments_per_email: 5, + }, + secrets: { + imap_password: { set: true, masked: "****1a2b" }, + smtp_password: { set: true, masked: "****c3d4" }, + }, + ...overrides, + }; +} + +function websocketEntry(overrides: Record = {}) { + return { + display_name: "WebSocket", + available: true, + loaded: true, + install_hint: "", + error: "", + supports_test: true, + sdk_available: true, + fields: [ + { key: "host", type: "text", secret: false, required: false, help_key: "settings.channels.fields.websocket.host" }, + { key: "port", type: "text", secret: false, required: false, help_key: "settings.channels.fields.websocket.port" }, + { key: "unix_socket_path", type: "text", secret: false, required: false, help_key: "settings.channels.fields.websocket.unix_socket_path" }, + { key: "path", type: "text", secret: false, required: false, help_key: "settings.channels.fields.websocket.path" }, + { key: "token", type: "password", secret: true, required: false, help_key: "settings.channels.fields.websocket.token" }, + { key: "token_issue_path", type: "text", secret: false, required: false, help_key: "settings.channels.fields.websocket.token_issue_path" }, + { key: "token_issue_secret", type: "password", secret: true, required: false, help_key: "settings.channels.fields.websocket.token_issue_secret" }, + { key: "token_ttl_s", type: "text", secret: false, required: false, help_key: "settings.channels.fields.websocket.token_ttl_s" }, + { key: "websocket_requires_token", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.websocket.websocket_requires_token" }, + { key: "allow_from", type: "list", secret: false, required: false, help_key: "settings.channels.fields.websocket.allow_from" }, + { key: "streaming", type: "bool", secret: false, required: false, help_key: "settings.channels.fields.websocket.streaming" }, + { key: "max_message_bytes", type: "text", secret: false, required: false, help_key: "settings.channels.fields.websocket.max_message_bytes" }, + { key: "ping_interval_s", type: "text", secret: false, required: false, help_key: "settings.channels.fields.websocket.ping_interval_s" }, + { key: "ping_timeout_s", type: "text", secret: false, required: false, help_key: "settings.channels.fields.websocket.ping_timeout_s" }, + { key: "ssl_certfile", type: "text", secret: false, required: false, help_key: "settings.channels.fields.websocket.ssl_certfile" }, + { key: "ssl_keyfile", type: "text", secret: false, required: false, help_key: "settings.channels.fields.websocket.ssl_keyfile" }, + ], + values: { + enabled: false, + host: "127.0.0.1", + port: 8765, + unix_socket_path: "", + path: "/", + token_issue_path: "", + token_ttl_s: 300, + websocket_requires_token: true, + allow_from: ["*"], + streaming: true, + max_message_bytes: 37748736, + ping_interval_s: 20, + ping_timeout_s: 20, + ssl_certfile: "", + ssl_keyfile: "", + }, + secrets: { + token: { set: true, masked: "****9z8y" }, + token_issue_secret: { set: true, masked: "****7x6w" }, + }, + ...overrides, + }; +} + function channelsConfig(overrides: Record = {}) { return { config_path: "~/.vibe-trading/agent.json", @@ -146,6 +278,28 @@ async function renderQqExpanded() { expect(await screen.findByDisplayValue("102000001")).toBeInTheDocument(); } +/** Render the card with the Email config panel expanded. */ +async function renderEmailExpanded() { + apiMock.getChannelsConfig.mockResolvedValue(channelsConfig({ + channels: { dingtalk: dingtalkEntry(), email: emailEntry() }, + })); + render(); + await screen.findByText("IM Channels"); + fireEvent.click(await screen.findByRole("button", { name: "Configure Email" })); + expect(await screen.findByDisplayValue("imap.example.com")).toBeInTheDocument(); +} + +/** Render the card with the WebSocket config panel expanded. */ +async function renderWebsocketExpanded() { + apiMock.getChannelsConfig.mockResolvedValue(channelsConfig({ + channels: { dingtalk: dingtalkEntry(), websocket: websocketEntry() }, + })); + render(); + await screen.findByText("IM Channels"); + fireEvent.click(await screen.findByRole("button", { name: "Configure WebSocket" })); + expect(await screen.findByDisplayValue("127.0.0.1")).toBeInTheDocument(); +} + describe("ChannelSettings config panel", () => { beforeEach(async () => { await i18n.changeLanguage("en"); @@ -456,6 +610,104 @@ describe("ChannelSettings config panel", () => { expect(screen.queryByText(/Register on the QQ Open Platform/)).not.toBeInTheDocument(); }); + it("renders every Email field from the backend help_keys with localized labels", async () => { + await renderEmailExpanded(); + + expect(screen.getByText("Consent granted")).toBeInTheDocument(); + expect(screen.getByText("IMAP host")).toBeInTheDocument(); + expect(screen.getByText("IMAP username")).toBeInTheDocument(); + expect(screen.getByText("IMAP mailbox")).toBeInTheDocument(); + expect(screen.getByText("SMTP host")).toBeInTheDocument(); + expect(screen.getByText("From address")).toBeInTheDocument(); + expect(screen.getByText("Automatic replies")).toBeInTheDocument(); + expect(screen.getByText("After processing")).toBeInTheDocument(); + expect(screen.getByText("Allowed senders")).toBeInTheDocument(); + expect(screen.getByText("Verify DKIM")).toBeInTheDocument(); + expect(screen.getByText("Verify TLS certificate")).toBeInTheDocument(); + expect(screen.getByText("Allowed attachment types")).toBeInTheDocument(); + expect(screen.getByText("Max attachments per email")).toBeInTheDocument(); + expect(screen.getByText(/Inbound mail server host, e\.g\. imap\.gmail\.com/)).toBeInTheDocument(); + // Both anti-spoofing fields carry the same warning, so match them together. + expect(screen.getAllByText(/Disabling both DKIM and SPF accepts spoofed From headers/)).toHaveLength(2); + // Secrets keep the generic masked placeholder and password type. + const imapSecret = screen.getByPlaceholderText("Keep current (****1a2b)"); + expect(imapSecret).toHaveAttribute("type", "password"); + expect(imapSecret).toHaveValue(""); + const smtpSecret = screen.getByPlaceholderText("Keep current (****c3d4)"); + expect(smtpSecret).toHaveAttribute("type", "password"); + expect(smtpSecret).toHaveValue(""); + }); + + it("toggles the Email setup guide with its steps and external link", async () => { + await renderEmailExpanded(); + + expect(screen.queryByText(/IMAP and SMTP host names and ports/)).not.toBeInTheDocument(); + + const guideToggle = screen.getByRole("button", { name: "Email setup guide" }); + expect(guideToggle).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(guideToggle); + + expect(guideToggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText(/no public callback URL is needed/)).toBeInTheDocument(); + expect(screen.getByText(/IMAP and SMTP host names and ports/)).toBeInTheDocument(); + expect(screen.getByText(/create an app password/)).toBeInTheDocument(); + expect(screen.getByText(/it probes the IMAP login, the mailbox folder, and the SMTP login/)).toBeInTheDocument(); + expect(screen.getByText(/Tick Consent granted, then enable the channel/)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Open the Vibe-Trading docs" })) + .toHaveAttribute("href", "https://vibetrading.wiki/docs/"); + + fireEvent.click(guideToggle); + expect(guideToggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText(/IMAP and SMTP host names and ports/)).not.toBeInTheDocument(); + }); + + it("renders every WebSocket field from the backend help_keys with localized labels", async () => { + await renderWebsocketExpanded(); + + expect(screen.getByText("Bind host")).toBeInTheDocument(); + expect(screen.getByText("Bind port")).toBeInTheDocument(); + expect(screen.getByText("Unix socket path")).toBeInTheDocument(); + expect(screen.getByText("Upgrade path")).toBeInTheDocument(); + expect(screen.getByText("Static token")).toBeInTheDocument(); + expect(screen.getByText("Token issue path")).toBeInTheDocument(); + expect(screen.getByText("Require token")).toBeInTheDocument(); + expect(screen.getByText("Allowed clients")).toBeInTheDocument(); + expect(screen.getByText("Max message bytes")).toBeInTheDocument(); + expect(screen.getByText("TLS certificate path")).toBeInTheDocument(); + expect(screen.getByText(/wildcard host such as 0\.0\.0\.0 or ::/)).toBeInTheDocument(); + // Certificate and key share the same pairing rule, so match them together. + expect(screen.getAllByText(/Set both certificate and key, or neither/)).toHaveLength(2); + const tokenSecret = screen.getByPlaceholderText("Keep current (****9z8y)"); + expect(tokenSecret).toHaveAttribute("type", "password"); + expect(tokenSecret).toHaveValue(""); + const issueSecret = screen.getByPlaceholderText("Keep current (****7x6w)"); + expect(issueSecret).toHaveAttribute("type", "password"); + expect(issueSecret).toHaveValue(""); + }); + + it("toggles the WebSocket setup guide with its steps and external link", async () => { + await renderWebsocketExpanded(); + + expect(screen.queryByText(/keep 127\.0\.0\.1 for local-only use/)).not.toBeInTheDocument(); + + const guideToggle = screen.getByRole("button", { name: "WebSocket setup guide" }); + expect(guideToggle).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(guideToggle); + + expect(guideToggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText(/keep 127\.0\.0\.1 for local-only use/)).toBeInTheDocument(); + expect(screen.getByText(/token-issue path plus its bearer secret/)).toBeInTheDocument(); + expect(screen.getByText(/ws:\/\/host:port\/path\?client_id=\.\.\.&token=\.\.\./)).toBeInTheDocument(); + expect(screen.getByText(/hot-swaps the server/)).toBeInTheDocument(); + expect(screen.getByText(/validates the SSL certificate\/key material/)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Open the Vibe-Trading docs" })) + .toHaveAttribute("href", "https://vibetrading.wiki/docs/"); + + fireEvent.click(guideToggle); + expect(guideToggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText(/keep 127\.0\.0\.1 for local-only use/)).not.toBeInTheDocument(); + }); + it("renders no setup guide for a channel without a guide definition", async () => { apiMock.getChannelsConfig.mockResolvedValue(channelsConfig({ channels: { diff --git a/frontend/src/i18n/locales/ar.json b/frontend/src/i18n/locales/ar.json index 5db8f55d..2c778485 100644 --- a/frontend/src/i18n/locales/ar.json +++ b/frontend/src/i18n/locales/ar.json @@ -520,6 +520,194 @@ "label": "الحد الأقصى لحجم التنزيل (بايت)", "help": "حدّ أمان لتنزيل مرفق واحد (الافتراضي 209715200 = 200 ميغابايت)." } + }, + "email": { + "consent_granted": { + "label": "الموافقة ممنوحة", + "help": "مفتاح الإذن الرئيسي. لا تقرأ القناة البريد أو ترسله إلا بعد منح الموافقة صراحةً هنا." + }, + "imap_host": { + "label": "مضيف IMAP", + "help": "مضيف خادم البريد الوارد، مثل imap.gmail.com. يُستعلم عن البريد الوارد من هذا الخادم عبر IMAP." + }, + "imap_port": { + "label": "منفذ IMAP", + "help": "منفذ البريد الوارد (الافتراضي 993 مع SSL)." + }, + "imap_username": { + "label": "اسم مستخدم IMAP", + "help": "حساب صندوق البريد المستخدم لتسجيل الدخول عبر IMAP، وعادةً ما يكون عنوان البريد الكامل." + }, + "imap_password": { + "label": "كلمة مرور IMAP", + "help": "كلمة مرور صندوق البريد أو كلمة مرور التطبيق لـ IMAP. تُحفظ على الخادم فقط ولا تُعاد إلى المتصفح أبدًا." + }, + "imap_mailbox": { + "label": "مجلد IMAP", + "help": "المجلد الذي يُستعلم عنه بحثًا عن رسائل جديدة (الافتراضي INBOX)." + }, + "imap_use_ssl": { + "label": "IMAP عبر SSL", + "help": "الاتصال بـ IMAP عبر SSL (مفعّل افتراضيًا، ومطلوب للمنفذ 993)." + }, + "smtp_host": { + "label": "مضيف SMTP", + "help": "مضيف خادم البريد الصادر، مثل smtp.gmail.com. تُرسل الردود عبر هذا الخادم." + }, + "smtp_port": { + "label": "منفذ SMTP", + "help": "منفذ البريد الصادر (الافتراضي 587 مع STARTTLS، و465 لـ SSL الضمني)." + }, + "smtp_username": { + "label": "اسم مستخدم SMTP", + "help": "الحساب المستخدم للمصادقة مع خادم SMTP، وعادةً ما يكون عنوان البريد الكامل." + }, + "smtp_password": { + "label": "كلمة مرور SMTP", + "help": "كلمة مرور SMTP أو كلمة مرور التطبيق. تُحفظ على الخادم فقط ولا تُعاد إلى المتصفح أبدًا." + }, + "smtp_use_tls": { + "label": "STARTTLS لـ SMTP", + "help": "ترقية اتصال SMTP عبر STARTTLS (مفعّل افتراضيًا، وهو المعتاد للمنفذ 587). لا يُستخدم عمليًا مع SMTP SSL في الوقت نفسه." + }, + "smtp_use_ssl": { + "label": "SMTP عبر SSL", + "help": "الاتصال بـ SMTP عبر TLS الضمني، مثل المنفذ 465 (معطّل افتراضيًا). لا يُستخدم عمليًا مع STARTTLS في الوقت نفسه." + }, + "verify_tls": { + "label": "التحقق من شهادة TLS", + "help": "التحقق من شهادة TLS الخاصة بخادم البريد واسم المضيف (موصى به). لا تُعطّله إلا مع الخوادم ذاتية التوقيع أو التي تستخدم CA داخلي." + }, + "from_address": { + "label": "عنوان المرسل", + "help": "العنوان الذي تُرسل منه الردود. إذا تُرك فارغًا يُستخدم اسم مستخدم SMTP." + }, + "auto_reply_enabled": { + "label": "الردود التلقائية", + "help": "الرد تلقائيًا على البريد الوارد. لا يتأثر الإرسال الذي يبدأه الوكيل." + }, + "poll_interval_seconds": { + "label": "فاصل الاستعلام (ثوانٍ)", + "help": "معدل فحص صندوق IMAP بحثًا عن بريد جديد (الافتراضي 30، والحد الأدنى 5)." + }, + "mark_seen": { + "label": "وضع علامة مقروء", + "help": "وضع علامة مقروء على الرسائل المعالجة في صندوق البريد." + }, + "post_action": { + "label": "إجراء ما بعد المعالجة", + "help": "ما يحدث للرسائل المعالجة: delete للحذف، أو move للنقل إلى مجلد آخر، أو اتركه فارغًا للإبقاء عليها." + }, + "post_action_move_mailbox": { + "label": "النقل إلى مجلد", + "help": "المجلد الهدف المستخدم عندما يكون إجراء ما بعد المعالجة هو move." + }, + "post_action_expunge": { + "label": "حذف نهائي بعد الإزالة", + "help": "إزالة الرسائل المحذوفة نهائيًا (expunge) بدل تركها بعلامة الحذف." + }, + "post_action_ignore_skipped": { + "label": "تجاهل رسائلك والمرشّحة", + "help": "ترك الرسائل المتخطاة (رسائلك ورسائل المرسلين المستبعدين بالقائمة) دون تطبيق إجراء ما بعد المعالجة (مفعّل افتراضيًا)." + }, + "max_body_chars": { + "label": "الحد الأقصى لأحرف النص", + "help": "اقتطاع نص كل رسالة عند هذا العدد من الأحرف (الافتراضي 12000)." + }, + "subject_prefix": { + "label": "بادئة عنوان الرد", + "help": "البادئة المضافة إلى عنوان الرد (الافتراضي \"Re: \")." + }, + "allow_from": { + "label": "المرسلون المسموحون", + "help": "قائمة سماح اختيارية بعناوين البريد التي يمكنها التحدث إلى الوكيل (\"*\" تسمح للجميع). المرسلون غير المقترنين يتلقون رمز اقتران في الرد." + }, + "verify_dkim": { + "label": "التحقق من DKIM", + "help": "طلب ترويسة Authentication-Results بالقيمة dkim=pass. تعطيل DKIM وSPF معًا يقبل ترويسات From المزوّرة." + }, + "verify_spf": { + "label": "التحقق من SPF", + "help": "طلب ترويسة Authentication-Results بالقيمة spf=pass. تعطيل DKIM وSPF معًا يقبل ترويسات From المزوّرة." + }, + "allowed_attachment_types": { + "label": "أنواع المرفقات المسموح بها", + "help": "قائمة سماح MIME لمرفقات البريد الوارد، مثل application/pdf أو image/*. اتركها فارغة لتعطيل المرفقات." + }, + "max_attachment_size": { + "label": "الحد الأقصى لحجم المرفق (بايت)", + "help": "أكبر حجم مسموح لمرفق واحد بالبايت (الافتراضي 2000000 = 2 ميغابايت)." + }, + "max_attachments_per_email": { + "label": "الحد الأقصى للمرفقات لكل رسالة", + "help": "عدد المرفقات المقبولة من رسالة واحدة (الافتراضي 5)." + } + }, + "websocket": { + "host": { + "label": "عنوان الربط", + "help": "العنوان المحلي الذي يرتبط به خادم WebSocket (الافتراضي 127.0.0.1، محلي فقط). العناوين الشاملة مثل 0.0.0.0 أو :: تكشف الخادم على كل الواجهات وتُرفض ما لم يُضبط رمز أو سرّ إصدار الرموز." + }, + "port": { + "label": "منفذ الربط", + "help": "المنفذ المحلي الذي يستمع عليه خادم WebSocket (الافتراضي 8765). يُتجاهل عند ضبط مسار Unix socket." + }, + "unix_socket_path": { + "label": "مسار Unix socket", + "help": "مسار مطلق اختياري. عند ضبطه يستمع الخادم على هذا الـ Unix socket بدل العنوان والمنفذ." + }, + "path": { + "label": "مسار الترقية", + "help": "مسار ترقية WebSocket الذي يتصل به العملاء (الافتراضي /). يجب أن يبدأ بـ \"/\"." + }, + "token": { + "label": "رمز ثابت", + "help": "سرّ مشترك يُتحقق منه عبر وسيط token في الطلب أثناء المصافحة. يُحفظ على الخادم فقط ولا يُعاد إلى المتصفح أبدًا." + }, + "token_issue_path": { + "label": "مسار إصدار الرموز", + "help": "مسار HTTP GET يعيد رموزًا قصيرة العمر لمرة واحدة بصيغة JSON. يجب أن يختلف عن مسار الترقية." + }, + "token_issue_secret": { + "label": "سرّ إصدار الرموز", + "help": "السرّ من نوع Bearer الذي يطلبه مسار الإصدار (Authorization: Bearer أو X-Vibe-Trading-Auth). يُحفظ على الخادم فقط ولا يُعاد إلى المتصفح أبدًا." + }, + "token_ttl_s": { + "label": "عمر الرمز الصادر (ثوانٍ)", + "help": "مدة صلاحية الرمز الذي يصدره مسار الإصدار بالثواني (30–86400، الافتراضي 300)." + }, + "websocket_requires_token": { + "label": "طلب رمز إلزامي", + "help": "رفض المصافحات التي لا تحمل رمزًا ثابتًا أو صادرًا صالحًا (مفعّل افتراضيًا)." + }, + "allow_from": { + "label": "العملاء المسموحون", + "help": "قائمة سماح اختيارية بمعرّفات العملاء الذين يمكنهم الاتصال (\"*\" تسمح للجميع، الافتراضي)." + }, + "streaming": { + "label": "بث الإخراج", + "help": "بث فروقات إخراج النموذج إلى العملاء المتصلين أثناء توليدها." + }, + "max_message_bytes": { + "label": "الحد الأقصى للرسالة (بايت)", + "help": "أكبر حجم مسموح لإطار وارد بالبايت (الافتراضي 37748736، والنطاق 1024–41943040)." + }, + "ping_interval_s": { + "label": "فاصل Ping (ثوانٍ)", + "help": "عدد الثواني بين نبضات الإبقاء على الاتصال (5–300، الافتراضي 20)." + }, + "ping_timeout_s": { + "label": "مهلة Ping (ثوانٍ)", + "help": "عدد الثواني لانتظار ردّ ping قبل قطع الاتصال (5–300، الافتراضي 20)." + }, + "ssl_certfile": { + "label": "مسار شهادة TLS", + "help": "مسار شهادة PEM على الخادم لـ WSS. اضبط الشهادة والمفتاح معًا أو اتركهما معًا؛ فهذه مسارات ملفات على الخادم وليست مواد مرفوعة." + }, + "ssl_keyfile": { + "label": "مسار مفتاح TLS", + "help": "مسار المفتاح الخاص PEM على الخادم لـ WSS. اضبط الشهادة والمفتاح معًا أو اتركهما معًا؛ فهذه مسارات ملفات على الخادم وليست مواد مرفوعة." + } } }, "guides": { @@ -542,6 +730,26 @@ "step4": "أضف الروبوت إلى مجموعة QQ أو تحدّث معه بشكل خاص، وفقًا لقواعد الصندوق الرملي/النشر الخاصة بالمنصة.", "step5": "عد إلى هنا، وانقر على «اختبار الاتصال»، ثم فعّل القناة.", "docsLabel": "فتح منصة QQ المفتوحة" + }, + "email": { + "title": "دليل إعداد البريد الإلكتروني", + "intro": "تستعلم قناة البريد عن الوارد عبر IMAP وترد عبر SMTP، لذا لا حاجة إلى عنوان استدعاء عام.", + "step1": "احصل على أسماء المضيفين والمنافذ لـ IMAP وSMTP من مزوّد البريد (عادةً imap.<المزوّد> على المنفذ 993 وsmtp.<المزوّد> على المنفذ 587).", + "step2": "فعّل IMAP وSMTP في إعدادات صندوق البريد، وأنشئ كلمة مرور تطبيق إذا طلب المزوّد ذلك (في Gmail وبريد QQ وغيرهما مع التحقق بخطوتين).", + "step3": "املأ بيانات اعتماد IMAP وSMTP أعلاه، واضبط عنوان المرسل، وأضف عنوانك إلى «المرسلون المسموحون».", + "step4": "انقر «اختبار الاتصال»؛ فهو يتحقق من تسجيل الدخول إلى IMAP ومن مجلد البريد ومن تسجيل الدخول إلى SMTP.", + "step5": "حدّد «الموافقة ممنوحة» ثم فعّل القناة؛ عندها تصل الردود التلقائية إلى المرسلين الذين يجتازون فحصي DKIM/SPF.", + "docsLabel": "افتح وثائق Vibe-Trading" + }, + "websocket": { + "title": "دليل إعداد WebSocket", + "intro": "تشغّل قناة WebSocket خادمًا محليًا يتصل به العملاء عبر عنوان WebSocket مباشرةً، لذا لا حاجة إلى عنوان استدعاء عام.", + "step1": "اختر عنوان الربط: أبقِ 127.0.0.1 للاستخدام المحلي فقط، أو اضبط 0.0.0.0 / :: لكشف الخادم — والعنوان الشامل يتطلب رمزًا أو سرّ إصدار رموز.", + "step2": "اضبط رمزًا ثابتًا، أو مسار إصدار رموز مع سرّه من نوع Bearer ليحصل العملاء على رموز قصيرة العمر.", + "step3": "يتصل العملاء عبر ws://host:port/path?client_id=...&token=...", + "step4": "الحفظ أثناء تشغيل القناة يستبدل الخادم فوريًا: ينقطع العملاء المتصلون (بما في ذلك محادثة واجهة الويب هذه) لحظيًا ثم يعيدون الاتصال تلقائيًا.", + "step5": "انقر «اختبار الاتصال»؛ فهو يتحقق محليًا من مادة شهادة/مفتاح SSL ومن توفر العنوان.", + "docsLabel": "افتح وثائق Vibe-Trading" } } }, diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index e973bacc..a208640b 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -525,6 +525,194 @@ "label": "Maximale Downloadgröße (Bytes)", "help": "Sicherheitsobergrenze für einen einzelnen Anhang-Download (Standard 209715200 = 200 MB)." } + }, + "email": { + "consent_granted": { + "label": "Einwilligung erteilt", + "help": "Hauptschalter: Der Kanal liest und versendet E-Mails erst, nachdem du hier ausdrücklich eingewilligt hast." + }, + "imap_host": { + "label": "IMAP-Host", + "help": "Server für eingehende E-Mails, z. B. imap.gmail.com. Neue Nachrichten werden per IMAP von diesem Server abgerufen." + }, + "imap_port": { + "label": "IMAP-Port", + "help": "Port für eingehende E-Mails (Standard 993 mit SSL)." + }, + "imap_username": { + "label": "IMAP-Benutzername", + "help": "Postfachkonto für die IMAP-Anmeldung, meist die vollständige E-Mail-Adresse." + }, + "imap_password": { + "label": "IMAP-Passwort", + "help": "Postfach- oder App-Passwort für IMAP. Wird serverseitig gespeichert und nie an den Browser zurückgegeben." + }, + "imap_mailbox": { + "label": "IMAP-Postfach", + "help": "Ordner, der auf neue E-Mails geprüft wird (Standard INBOX)." + }, + "imap_use_ssl": { + "label": "IMAP-SSL", + "help": "IMAP über SSL verbinden (standardmäßig aktiv; erforderlich für Port 993)." + }, + "smtp_host": { + "label": "SMTP-Host", + "help": "Server für ausgehende E-Mails, z. B. smtp.gmail.com. Antworten werden über diesen Server versendet." + }, + "smtp_port": { + "label": "SMTP-Port", + "help": "Port für ausgehende E-Mails (Standard 587 mit STARTTLS; 465 für implizites SSL)." + }, + "smtp_username": { + "label": "SMTP-Benutzername", + "help": "Konto für die SMTP-Authentifizierung, meist die vollständige E-Mail-Adresse." + }, + "smtp_password": { + "label": "SMTP-Passwort", + "help": "Passwort oder App-Passwort für SMTP. Wird serverseitig gespeichert und nie an den Browser zurückgegeben." + }, + "smtp_use_tls": { + "label": "SMTP-STARTTLS", + "help": "SMTP-Verbindung per STARTTLS verschlüsseln (standardmäßig aktiv; üblich für Port 587). In der Praxis nicht mit SMTP-SSL kombinierbar." + }, + "smtp_use_ssl": { + "label": "SMTP-SSL", + "help": "SMTP über implizites TLS verbinden, z. B. Port 465 (standardmäßig aus). In der Praxis nicht mit STARTTLS kombinierbar." + }, + "verify_tls": { + "label": "TLS-Zertifikat prüfen", + "help": "TLS-Zertifikat und Hostname des Mailservers prüfen (empfohlen). Nur für selbstsignierte oder interne CA-Server deaktivieren." + }, + "from_address": { + "label": "Absenderadresse", + "help": "Adresse, von der Antworten versendet werden. Leer übernimmt den SMTP-Benutzernamen." + }, + "auto_reply_enabled": { + "label": "Automatische Antworten", + "help": "Auf eingehende E-Mails automatisch antworten. Vom Agenten gestartete Sendungen sind nicht betroffen." + }, + "poll_interval_seconds": { + "label": "Abrufintervall (s)", + "help": "Wie oft das IMAP-Postfach auf neue E-Mails geprüft wird (Standard 30, Minimum 5)." + }, + "mark_seen": { + "label": "Als gelesen markieren", + "help": "Verarbeitete E-Mails im Postfach als gelesen markieren." + }, + "post_action": { + "label": "Aktion nach Verarbeitung", + "help": "Behandlung verarbeiteter E-Mails: delete (löschen), move (in einen anderen Ordner verschieben) oder leer lassen zum Behalten." + }, + "post_action_move_mailbox": { + "label": "Verschieben nach Ordner", + "help": "Zielordner, wenn die Aktion nach Verarbeitung move ist." + }, + "post_action_expunge": { + "label": "Nach Löschen endgültig entfernen", + "help": "Gelöschte E-Mails endgültig entfernen (expunge), statt sie nur zu markieren." + }, + "post_action_ignore_skipped": { + "label": "Eigene und gefilterte Mail ausnehmen", + "help": "Übersprungene E-Mails (eigene Nachrichten und durch die Liste gefilterte Absender) von der Aktion ausnehmen (standardmäßig aktiv)." + }, + "max_body_chars": { + "label": "Max. Zeichen im Text", + "help": "Text jeder E-Mail auf diese Zeichenzahl kürzen (Standard 12000)." + }, + "subject_prefix": { + "label": "Betreff-Präfix", + "help": "Präfix für Antwort-Betreffzeilen (Standard \"Re: \")." + }, + "allow_from": { + "label": "Erlaubte Absender", + "help": "Optionale Positivliste von E-Mail-Adressen, die mit dem Agenten sprechen dürfen (\"*\" erlaubt alle). Nicht gekoppelte Absender erhalten einen Kopplungscode als Antwort." + }, + "verify_dkim": { + "label": "DKIM prüfen", + "help": "Authentication-Results-Header mit dkim=pass verlangen. Werden DKIM und SPF zugleich deaktiviert, werden gefälschte From-Header akzeptiert." + }, + "verify_spf": { + "label": "SPF prüfen", + "help": "Authentication-Results-Header mit spf=pass verlangen. Werden DKIM und SPF zugleich deaktiviert, werden gefälschte From-Header akzeptiert." + }, + "allowed_attachment_types": { + "label": "Erlaubte Anhangstypen", + "help": "MIME-Positivliste für eingehende Anhänge, z. B. application/pdf oder image/*. Leer lassen deaktiviert Anhänge." + }, + "max_attachment_size": { + "label": "Max. Anhangsgröße (Bytes)", + "help": "Größter zulässiger Einzelanhang in Bytes (Standard 2000000 = 2 MB)." + }, + "max_attachments_per_email": { + "label": "Max. Anhänge pro E-Mail", + "help": "Wie viele Anhänge aus einer E-Mail übernommen werden (Standard 5)." + } + }, + "websocket": { + "host": { + "label": "Bind-Adresse", + "help": "Lokale Adresse, an die sich der WebSocket-Server bindet (Standard 127.0.0.1, nur lokal). Eine Wildcard-Adresse wie 0.0.0.0 oder :: legt den Server auf allen Schnittstellen offen und wird abgelehnt, solange kein Token oder Ausgabegeheimnis gesetzt ist." + }, + "port": { + "label": "Bind-Port", + "help": "Lokaler Port des WebSocket-Servers (Standard 8765). Wird ignoriert, wenn ein Unix-Socket-Pfad gesetzt ist." + }, + "unix_socket_path": { + "label": "Unix-Socket-Pfad", + "help": "Optionaler absoluter Pfad. Ist er gesetzt, lauscht der Server an diesem Unix-Socket statt an Adresse und Port." + }, + "path": { + "label": "Upgrade-Pfad", + "help": "WebSocket-Upgrade-Pfad für Clients (Standard /). Muss mit \"/\" beginnen." + }, + "token": { + "label": "Statisches Token", + "help": "Gemeinsames Geheimnis, das beim Handshake über den Parameter token geprüft wird. Wird serverseitig gespeichert und nie an den Browser zurückgegeben." + }, + "token_issue_path": { + "label": "Token-Ausgabepfad", + "help": "HTTP-GET-Route, die kurzlebige Einmal-Token als JSON ausgibt. Muss sich vom Upgrade-Pfad unterscheiden." + }, + "token_issue_secret": { + "label": "Ausgabegeheimnis", + "help": "Bearer-Geheimnis, das die Ausgaberoute verlangt (Authorization: Bearer oder X-Vibe-Trading-Auth). Wird serverseitig gespeichert und nie an den Browser zurückgegeben." + }, + "token_ttl_s": { + "label": "TTL ausgegebener Token (s)", + "help": "Lebensdauer eines von der Ausgaberoute ausgestellten Tokens in Sekunden (30-86400, Standard 300)." + }, + "websocket_requires_token": { + "label": "Token verlangen", + "help": "Handshakes ohne gültiges statisches oder ausgegebenes Token abweisen (standardmäßig aktiv)." + }, + "allow_from": { + "label": "Erlaubte Clients", + "help": "Optionale Positivliste von Client-IDs, die sich verbinden dürfen (\"*\" erlaubt alle, Standard)." + }, + "streaming": { + "label": "Ausgabe streamen", + "help": "Modell-Ausgabedeltas an verbundene Clients senden, sobald sie entstehen." + }, + "max_message_bytes": { + "label": "Max. Nachrichtengröße (Bytes)", + "help": "Größter zulässiger eingehender Frame in Bytes (Standard 37748736; 1024-41943040)." + }, + "ping_interval_s": { + "label": "Ping-Intervall (s)", + "help": "Sekunden zwischen Keepalive-Pings (5-300, Standard 20)." + }, + "ping_timeout_s": { + "label": "Ping-Timeout (s)", + "help": "Sekunden Wartezeit auf eine Ping-Antwort, bevor die Verbindung getrennt wird (5-300, Standard 20)." + }, + "ssl_certfile": { + "label": "TLS-Zertifikatspfad", + "help": "PEM-Zertifikatspfad auf dem Server für WSS. Zertifikat und Schlüssel gemeinsam setzen oder beide leer lassen; es sind Dateipfade auf dem Server, kein hochgeladenes Material." + }, + "ssl_keyfile": { + "label": "TLS-Schlüsselpfad", + "help": "PEM-Schlüsselpfad auf dem Server für WSS. Zertifikat und Schlüssel gemeinsam setzen oder beide leer lassen; es sind Dateipfade auf dem Server, kein hochgeladenes Material." + } } }, "guides": { @@ -547,6 +735,26 @@ "step4": "Füge den Bot gemäß den Sandbox-/Veröffentlichungsregeln der Plattform einer QQ-Gruppe hinzu oder chatte privat mit ihm.", "step5": "Kehre hierher zurück, klicke auf „Verbindung testen“ und aktiviere den Kanal.", "docsLabel": "QQ Open Platform öffnen" + }, + "email": { + "title": "E-Mail-Einrichtungsanleitung", + "intro": "Der E-Mail-Kanal ruft eingehende Mail per IMAP ab und antwortet über SMTP – eine öffentliche Callback-URL ist nicht nötig.", + "step1": "Ermittle Hostnamen und Ports für IMAP und SMTP bei deinem Anbieter (meist imap. auf 993 und smtp. auf 587).", + "step2": "Aktiviere IMAP und SMTP in den Postfach-Einstellungen und erstelle – falls der Anbieter es verlangt (Gmail, QQ Mail und andere mit 2FA) – ein App-Passwort.", + "step3": "Trage die IMAP- und SMTP-Zugangsdaten oben ein, setze die Absenderadresse und nimm deine eigene Adresse in „Erlaubte Absender“ auf.", + "step4": "Klicke auf „Verbindung testen“ – geprüft werden IMAP-Anmeldung, Postfachordner und SMTP-Anmeldung.", + "step5": "Setze „Einwilligung erteilt“ und aktiviere den Kanal; automatische Antworten erreichen dann Absender, die die DKIM-/SPF-Prüfung bestehen.", + "docsLabel": "Vibe-Trading-Dokumentation öffnen" + }, + "websocket": { + "title": "WebSocket-Einrichtungsanleitung", + "intro": "Der WebSocket-Kanal betreibt einen eigenen lokalen Server, mit dem sich Clients direkt über eine WebSocket-URL verbinden – eine öffentliche Callback-URL ist nicht nötig.", + "step1": "Wähle eine Bind-Adresse: Für rein lokalen Betrieb 127.0.0.1 beibehalten, zum Offenlegen 0.0.0.0 oder :: setzen – eine Wildcard-Adresse erfordert ein Token oder ein Ausgabegeheimnis.", + "step2": "Setze ein statisches Token oder einen Token-Ausgabepfad samt Bearer-Geheimnis, damit Clients kurzlebige Token anfordern können.", + "step3": "Verbinde Clients mit ws://host:port/path?client_id=...&token=...", + "step4": "Speichern im laufenden Betrieb tauscht den Server live aus: Verbundene Clients (einschließlich dieses Web-UI-Chats) verlieren kurz die Verbindung und verbinden sich neu.", + "step5": "Klicke auf „Verbindung testen“ – Zertifikat/Schlüssel für SSL und die Verfügbarkeit der Adresse werden lokal geprüft.", + "docsLabel": "Vibe-Trading-Dokumentation öffnen" } } }, diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c3836213..e06faffb 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -525,6 +525,194 @@ "label": "Max download size (bytes)", "help": "Safety cap for a single attachment download (default 209715200 = 200 MB)." } + }, + "email": { + "consent_granted": { + "label": "Consent granted", + "help": "Master permission switch. The channel reads and sends email only after you explicitly grant consent here." + }, + "imap_host": { + "label": "IMAP host", + "help": "Inbound mail server host, e.g. imap.gmail.com. Incoming mail is polled from this server." + }, + "imap_port": { + "label": "IMAP port", + "help": "Inbound port (default 993 with SSL)." + }, + "imap_username": { + "label": "IMAP username", + "help": "Mailbox account used to sign in over IMAP, usually the full email address." + }, + "imap_password": { + "label": "IMAP password", + "help": "Mailbox password or app password for IMAP. Stored server-side and never sent back to the browser." + }, + "imap_mailbox": { + "label": "IMAP mailbox", + "help": "Mailbox folder polled for new mail (default INBOX)." + }, + "imap_use_ssl": { + "label": "IMAP SSL", + "help": "Connect to IMAP over SSL (on by default; required for port 993)." + }, + "smtp_host": { + "label": "SMTP host", + "help": "Outbound mail server host, e.g. smtp.gmail.com. Replies are sent through this server." + }, + "smtp_port": { + "label": "SMTP port", + "help": "Outbound port (default 587 with STARTTLS; use 465 for implicit SSL)." + }, + "smtp_username": { + "label": "SMTP username", + "help": "Account used to authenticate with the SMTP server, usually the full email address." + }, + "smtp_password": { + "label": "SMTP password", + "help": "Password or app password for SMTP. Stored server-side and never sent back to the browser." + }, + "smtp_use_tls": { + "label": "SMTP STARTTLS", + "help": "Upgrade the SMTP connection with STARTTLS (on by default; typical for port 587). In practice this is mutually exclusive with SMTP SSL." + }, + "smtp_use_ssl": { + "label": "SMTP SSL", + "help": "Connect to SMTP over implicit TLS, e.g. port 465 (off by default). In practice this is mutually exclusive with STARTTLS." + }, + "verify_tls": { + "label": "Verify TLS certificate", + "help": "Verify the mail server's TLS certificate and hostname (recommended). Disable only for self-signed or internal-CA servers." + }, + "from_address": { + "label": "From address", + "help": "Address replies are sent from. Empty reuses the SMTP username." + }, + "auto_reply_enabled": { + "label": "Automatic replies", + "help": "Reply automatically to inbound email. Proactive sends started by the agent are unaffected." + }, + "poll_interval_seconds": { + "label": "Poll interval (seconds)", + "help": "How often the IMAP mailbox is checked for new mail (default 30, floor 5)." + }, + "mark_seen": { + "label": "Mark as seen", + "help": "Mark processed emails as read in the mailbox." + }, + "post_action": { + "label": "After processing", + "help": "What happens to processed emails: delete, move them to another mailbox, or leave empty to keep them." + }, + "post_action_move_mailbox": { + "label": "Move to mailbox", + "help": "Target mailbox used when After processing is set to move." + }, + "post_action_expunge": { + "label": "Expunge after delete", + "help": "Permanently expunge deleted emails instead of leaving them flagged in the mailbox." + }, + "post_action_ignore_skipped": { + "label": "Skip self and filtered mail", + "help": "Leave skipped emails (your own messages and senders filtered by the allowlist) untouched by the post-action (on by default)." + }, + "max_body_chars": { + "label": "Max body chars", + "help": "Truncate each email body to this many characters (default 12000)." + }, + "subject_prefix": { + "label": "Reply subject prefix", + "help": "Prefix added to reply subjects (default \"Re: \")." + }, + "allow_from": { + "label": "Allowed senders", + "help": "Optional allowlist of email addresses that may talk to the agent (\"*\" allows everyone). Unpaired senders receive a pairing code in a reply." + }, + "verify_dkim": { + "label": "Verify DKIM", + "help": "Require an Authentication-Results header with dkim=pass. Disabling both DKIM and SPF accepts spoofed From headers." + }, + "verify_spf": { + "label": "Verify SPF", + "help": "Require an Authentication-Results header with spf=pass. Disabling both DKIM and SPF accepts spoofed From headers." + }, + "allowed_attachment_types": { + "label": "Allowed attachment types", + "help": "MIME allowlist for inbound attachments, e.g. application/pdf or image/*. Leave empty to disable attachments." + }, + "max_attachment_size": { + "label": "Max attachment size (bytes)", + "help": "Largest single attachment accepted, in bytes (default 2000000 = 2 MB)." + }, + "max_attachments_per_email": { + "label": "Max attachments per email", + "help": "How many attachments are accepted from one email (default 5)." + } + }, + "websocket": { + "host": { + "label": "Bind host", + "help": "Local address the WebSocket server binds to (default 127.0.0.1, local-only). A wildcard host such as 0.0.0.0 or :: exposes the server on every interface and is rejected unless a token or token-issue secret is set." + }, + "port": { + "label": "Bind port", + "help": "Local port the WebSocket server listens on (default 8765). Ignored when a Unix socket path is set." + }, + "unix_socket_path": { + "label": "Unix socket path", + "help": "Optional absolute path. When set, the server listens on this Unix socket instead of the host and port." + }, + "path": { + "label": "Upgrade path", + "help": "WebSocket upgrade path clients connect to (default /). Must start with \"/\"." + }, + "token": { + "label": "Static token", + "help": "Shared secret checked as the token query parameter during the handshake. Stored server-side and never sent back to the browser." + }, + "token_issue_path": { + "label": "Token issue path", + "help": "HTTP GET route that hands out short-lived one-time tokens as JSON. Must differ from the upgrade path." + }, + "token_issue_secret": { + "label": "Token issue secret", + "help": "Bearer secret the token-issue route requires (Authorization: Bearer or X-Vibe-Trading-Auth). Stored server-side and never sent back to the browser." + }, + "token_ttl_s": { + "label": "Issued token TTL (s)", + "help": "Lifetime of a token handed out by the token-issue route, in seconds (30-86400, default 300)." + }, + "websocket_requires_token": { + "label": "Require token", + "help": "Reject handshakes that do not present a valid static or issued token (on by default)." + }, + "allow_from": { + "label": "Allowed clients", + "help": "Optional allowlist of client IDs that may connect (\"*\" allows everyone, the default)." + }, + "streaming": { + "label": "Stream output", + "help": "Stream model output deltas to connected clients as they are generated." + }, + "max_message_bytes": { + "label": "Max message bytes", + "help": "Largest inbound frame accepted, in bytes (default 37748736; range 1024-41943040)." + }, + "ping_interval_s": { + "label": "Ping interval (s)", + "help": "Seconds between keepalive pings (5-300, default 20)." + }, + "ping_timeout_s": { + "label": "Ping timeout (s)", + "help": "Seconds to wait for a ping reply before dropping the connection (5-300, default 20)." + }, + "ssl_certfile": { + "label": "TLS certificate path", + "help": "PEM certificate path on the server for WSS. Set both certificate and key, or neither; these are file paths on the server, not uploaded material." + }, + "ssl_keyfile": { + "label": "TLS key path", + "help": "PEM private key path on the server for WSS. Set both certificate and key, or neither; these are file paths on the server, not uploaded material." + } } }, "guides": { @@ -547,6 +735,26 @@ "step4": "Add the bot to a QQ group or chat with it privately, following the platform's sandbox/publish rules.", "step5": "Return here, click Test connection, then enable the channel.", "docsLabel": "Open the QQ Open Platform" + }, + "email": { + "title": "Email setup guide", + "intro": "The email channel polls a mailbox over IMAP for incoming mail and replies over SMTP, so no public callback URL is needed.", + "step1": "Get the IMAP and SMTP host names and ports from your mail provider (usually imap. on 993 and smtp. on 587).", + "step2": "Enable IMAP and SMTP access in your mailbox settings and, if the provider requires one (Gmail, QQ Mail and other 2FA providers), create an app password.", + "step3": "Fill in the IMAP and SMTP credentials above, set From address, and add your own address to Allowed senders.", + "step4": "Click Test connection — it probes the IMAP login, the mailbox folder, and the SMTP login.", + "step5": "Tick Consent granted, then enable the channel; automatic replies then reach senders whose mail passes the DKIM/SPF checks.", + "docsLabel": "Open the Vibe-Trading docs" + }, + "websocket": { + "title": "WebSocket setup guide", + "intro": "The WebSocket channel runs its own local server that clients connect to directly over a WebSocket URL, so no public callback URL is needed.", + "step1": "Choose a bind address: keep 127.0.0.1 for local-only use, or set 0.0.0.0 / :: to expose the server — a wildcard host requires a token or a token-issue secret.", + "step2": "Set a static token, or a token-issue path plus its bearer secret so clients can request short-lived tokens.", + "step3": "Connect clients with ws://host:port/path?client_id=...&token=...", + "step4": "Saving while the channel is running hot-swaps the server: connected clients (including this Web UI chat) briefly disconnect and reconnect.", + "step5": "Click Test connection — it validates the SSL certificate/key material and the address availability locally.", + "docsLabel": "Open the Vibe-Trading docs" } } }, diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 09ed8616..9cc1d7ff 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -525,6 +525,194 @@ "label": "Tamaño máximo de descarga (bytes)", "help": "Límite de seguridad para la descarga de un solo archivo adjunto (predeterminado 209715200 = 200 MB)." } + }, + "email": { + "consent_granted": { + "label": "Consentimiento otorgado", + "help": "Interruptor de permiso principal. El canal solo lee y envía correo después de que otorgues el consentimiento explícitamente." + }, + "imap_host": { + "label": "Host IMAP", + "help": "Servidor de correo entrante, p. ej. imap.gmail.com. El correo entrante se sondea desde este servidor." + }, + "imap_port": { + "label": "Puerto IMAP", + "help": "Puerto entrante (predeterminado 993 con SSL)." + }, + "imap_username": { + "label": "Usuario IMAP", + "help": "Cuenta del buzón para iniciar sesión por IMAP; normalmente es la dirección de correo completa." + }, + "imap_password": { + "label": "Contraseña IMAP", + "help": "Contraseña del buzón o contraseña de aplicación para IMAP. Se guarda en el servidor y nunca se devuelve al navegador." + }, + "imap_mailbox": { + "label": "Buzón IMAP", + "help": "Carpeta que se sondea en busca de correo nuevo (predeterminada INBOX)." + }, + "imap_use_ssl": { + "label": "SSL de IMAP", + "help": "Conectar a IMAP mediante SSL (activado de forma predeterminada; necesario para el puerto 993)." + }, + "smtp_host": { + "label": "Host SMTP", + "help": "Servidor de correo saliente, p. ej. smtp.gmail.com. Las respuestas se envían a través de este servidor." + }, + "smtp_port": { + "label": "Puerto SMTP", + "help": "Puerto saliente (predeterminado 587 con STARTTLS; 465 para SSL implícito)." + }, + "smtp_username": { + "label": "Usuario SMTP", + "help": "Cuenta para autenticarse con el servidor SMTP; normalmente es la dirección de correo completa." + }, + "smtp_password": { + "label": "Contraseña SMTP", + "help": "Contraseña o contraseña de aplicación para SMTP. Se guarda en el servidor y nunca se devuelve al navegador." + }, + "smtp_use_tls": { + "label": "STARTTLS de SMTP", + "help": "Cifrar la conexión SMTP con STARTTLS (activado de forma predeterminada; lo habitual en el puerto 587). En la práctica es excluyente con SSL de SMTP." + }, + "smtp_use_ssl": { + "label": "SSL de SMTP", + "help": "Conectar a SMTP mediante TLS implícito, p. ej. el puerto 465 (desactivado de forma predeterminada). En la práctica es excluyente con STARTTLS." + }, + "verify_tls": { + "label": "Verificar el certificado TLS", + "help": "Verificar el certificado TLS y el nombre de host del servidor de correo (recomendado). Desactivar solo para servidores con certificados autofirmados o de CA interna." + }, + "from_address": { + "label": "Dirección de remitente", + "help": "Dirección desde la que se envían las respuestas. Si se deja vacía, se usa el usuario SMTP." + }, + "auto_reply_enabled": { + "label": "Respuestas automáticas", + "help": "Responder automáticamente al correo entrante. Los envíos iniciados por el agente no se ven afectados." + }, + "poll_interval_seconds": { + "label": "Intervalo de sondeo (s)", + "help": "Con qué frecuencia se comprueba el buzón IMAP en busca de correo nuevo (predeterminado 30, mínimo 5)." + }, + "mark_seen": { + "label": "Marcar como leído", + "help": "Marcar como leídos los correos procesados en el buzón." + }, + "post_action": { + "label": "Acción posterior", + "help": "Qué hacer con los correos procesados: delete (eliminar), move (mover a otro buzón) o vacío para conservarlos." + }, + "post_action_move_mailbox": { + "label": "Mover a buzón", + "help": "Buzón de destino usado cuando la acción posterior es move." + }, + "post_action_expunge": { + "label": "Expurgar tras eliminar", + "help": "Eliminar definitivamente los correos borrados (expunge) en lugar de dejarlos marcados." + }, + "post_action_ignore_skipped": { + "label": "Omitir propios y filtrados", + "help": "No aplicar la acción posterior a los correos omitidos (los tuyos y los de remitentes filtrados por la lista) (activado de forma predeterminada)." + }, + "max_body_chars": { + "label": "Máx. de caracteres del cuerpo", + "help": "Truncar el cuerpo de cada correo a este número de caracteres (predeterminado 12000)." + }, + "subject_prefix": { + "label": "Prefijo del asunto", + "help": "Prefijo que se añade al asunto de las respuestas (predeterminado \"Re: \")." + }, + "allow_from": { + "label": "Remitentes permitidos", + "help": "Lista de direcciones autorizadas que pueden hablar con el agente (\"*\" permite a todos). Los remitentes no emparejados reciben un código de emparejamiento en la respuesta." + }, + "verify_dkim": { + "label": "Verificar DKIM", + "help": "Exigir una cabecera Authentication-Results con dkim=pass. Desactivar DKIM y SPF a la vez acepta cabeceras From falsificadas." + }, + "verify_spf": { + "label": "Verificar SPF", + "help": "Exigir una cabecera Authentication-Results con spf=pass. Desactivar DKIM y SPF a la vez acepta cabeceras From falsificadas." + }, + "allowed_attachment_types": { + "label": "Tipos de adjunto permitidos", + "help": "Lista MIME permitida para adjuntos entrantes, p. ej. application/pdf o image/*. Déjala vacía para desactivar los adjuntos." + }, + "max_attachment_size": { + "label": "Tamaño máx. de adjunto (bytes)", + "help": "Tamaño máximo por adjunto, en bytes (predeterminado 2000000 = 2 MB)." + }, + "max_attachments_per_email": { + "label": "Máx. adjuntos por correo", + "help": "Cuántos adjuntos se aceptan de un mismo correo (predeterminado 5)." + } + }, + "websocket": { + "host": { + "label": "Host de escucha", + "help": "Dirección local a la que se enlaza el servidor WebSocket (predeterminada 127.0.0.1, solo local). Un host comodín como 0.0.0.0 o :: expone el servidor en todas las interfaces y se rechaza salvo que se configure un token o un secreto de emisión." + }, + "port": { + "label": "Puerto de escucha", + "help": "Puerto local en el que escucha el servidor WebSocket (predeterminado 8765). Se ignora si se define una ruta de socket Unix." + }, + "unix_socket_path": { + "label": "Ruta de socket Unix", + "help": "Ruta absoluta opcional. Si se define, el servidor escucha en este socket Unix en lugar del host y el puerto." + }, + "path": { + "label": "Ruta de actualización", + "help": "Ruta de actualización WebSocket a la que se conectan los clientes (predeterminada /). Debe empezar por \"/\"." + }, + "token": { + "label": "Token estático", + "help": "Secreto compartido que se valida con el parámetro token durante el handshake. Se guarda en el servidor y nunca se devuelve al navegador." + }, + "token_issue_path": { + "label": "Ruta de emisión de tokens", + "help": "Ruta HTTP GET que entrega tokens de un solo uso y corta vida en JSON. Debe ser distinta de la ruta de actualización." + }, + "token_issue_secret": { + "label": "Secreto de emisión", + "help": "Secreto Bearer que exige la ruta de emisión (Authorization: Bearer o X-Vibe-Trading-Auth). Se guarda en el servidor y nunca se devuelve al navegador." + }, + "token_ttl_s": { + "label": "TTL del token emitido (s)", + "help": "Vida útil del token emitido por la ruta de emisión, en segundos (30-86400, predeterminado 300)." + }, + "websocket_requires_token": { + "label": "Exigir token", + "help": "Rechazar los handshakes que no presenten un token estático o emitido válido (activado de forma predeterminada)." + }, + "allow_from": { + "label": "Clientes permitidos", + "help": "Lista de IDs de cliente autorizados a conectarse (\"*\" permite a todos, valor predeterminado)." + }, + "streaming": { + "label": "Transmitir salida", + "help": "Enviar los deltas de salida del modelo a los clientes conectados mientras se generan." + }, + "max_message_bytes": { + "label": "Máx. bytes por mensaje", + "help": "Tamaño máximo del frame entrante, en bytes (predeterminado 37748736; 1024-41943040)." + }, + "ping_interval_s": { + "label": "Intervalo de ping (s)", + "help": "Segundos entre pings de keepalive (5-300, predeterminado 20)." + }, + "ping_timeout_s": { + "label": "Timeout de ping (s)", + "help": "Segundos de espera de una respuesta de ping antes de cerrar la conexión (5-300, predeterminado 20)." + }, + "ssl_certfile": { + "label": "Ruta del certificado TLS", + "help": "Ruta del certificado PEM en el servidor para WSS. Configura certificado y clave juntos o ninguno; son rutas de archivo del servidor, no material subido." + }, + "ssl_keyfile": { + "label": "Ruta de la clave TLS", + "help": "Ruta de la clave privada PEM en el servidor para WSS. Configura certificado y clave juntos o ninguno; son rutas de archivo del servidor, no material subido." + } } }, "guides": { @@ -547,6 +735,26 @@ "step4": "Añade el bot a un grupo de QQ o chatea con él en privado, siguiendo las reglas de sandbox/publicación de la plataforma.", "step5": "Vuelve aquí, pulsa «Probar conexión» y activa el canal.", "docsLabel": "Abrir la QQ Open Platform" + }, + "email": { + "title": "Guía de configuración de correo", + "intro": "El canal de correo sondea el buzón por IMAP y responde por SMTP, así que no necesita una URL de callback pública.", + "step1": "Obtén los hosts y puertos de IMAP y SMTP de tu proveedor (normalmente imap. en 993 y smtp. en 587).", + "step2": "Activa IMAP y SMTP en los ajustes de tu buzón y, si el proveedor lo exige (Gmail, QQ Mail y otros con verificación en dos pasos), crea una contraseña de aplicación.", + "step3": "Rellena las credenciales de IMAP y SMTP de arriba, define la dirección de remitente y añade tu propia dirección a «Remitentes permitidos».", + "step4": "Haz clic en «Probar conexión»: comprueba el inicio de sesión IMAP, la carpeta del buzón y el inicio de sesión SMTP.", + "step5": "Marca «Consentimiento otorgado» y activa el canal; las respuestas automáticas llegarán a los remitentes que superen las comprobaciones DKIM/SPF.", + "docsLabel": "Abrir la documentación de Vibe-Trading" + }, + "websocket": { + "title": "Guía de configuración de WebSocket", + "intro": "El canal WebSocket ejecuta su propio servidor local al que los clientes se conectan por una URL WebSocket, así que no necesita una URL de callback pública.", + "step1": "Elige una dirección de escucha: mantén 127.0.0.1 para uso solo local, o define 0.0.0.0 / :: para exponer el servidor; un host comodín exige un token o un secreto de emisión.", + "step2": "Define un token estático, o una ruta de emisión con su secreto Bearer para que los clientes pidan tokens de corta vida.", + "step3": "Conecta los clientes con ws://host:port/path?client_id=...&token=...", + "step4": "Guardar con el canal en marcha cambia el servidor en caliente: los clientes conectados (incluido este chat de la interfaz web) se desconectan un momento y vuelven a conectarse.", + "step5": "Haz clic en «Probar conexión»: valida localmente el certificado y la clave SSL y la disponibilidad de la dirección.", + "docsLabel": "Abrir la documentación de Vibe-Trading" } } }, diff --git a/frontend/src/i18n/locales/id.json b/frontend/src/i18n/locales/id.json index 8dca8aaf..e0fa6a20 100644 --- a/frontend/src/i18n/locales/id.json +++ b/frontend/src/i18n/locales/id.json @@ -525,6 +525,194 @@ "label": "Ukuran unduhan maksimum (byte)", "help": "Batas pengaman untuk satu unduhan lampiran (default 209715200 = 200 MB)." } + }, + "email": { + "consent_granted": { + "label": "Persetujuan diberikan", + "help": "Sakelar izin utama. Kanal hanya membaca dan mengirim email setelah Anda memberikan persetujuan secara eksplisit." + }, + "imap_host": { + "label": "Host IMAP", + "help": "Host server email masuk, misalnya imap.gmail.com. Email masuk diambil dari server ini melalui IMAP." + }, + "imap_port": { + "label": "Port IMAP", + "help": "Port masuk (default 993 dengan SSL)." + }, + "imap_username": { + "label": "Nama pengguna IMAP", + "help": "Akun kotak surat untuk masuk melalui IMAP, biasanya alamat email lengkap." + }, + "imap_password": { + "label": "Kata sandi IMAP", + "help": "Kata sandi kotak surat atau kata sandi aplikasi untuk IMAP. Disimpan di sisi server dan tidak pernah dikirim kembali ke browser." + }, + "imap_mailbox": { + "label": "Kotak surat IMAP", + "help": "Folder yang dipantau untuk email baru (default INBOX)." + }, + "imap_use_ssl": { + "label": "SSL IMAP", + "help": "Hubungkan ke IMAP melalui SSL (aktif secara default; diperlukan untuk port 993)." + }, + "smtp_host": { + "label": "Host SMTP", + "help": "Host server email keluar, misalnya smtp.gmail.com. Balasan dikirim melalui server ini." + }, + "smtp_port": { + "label": "Port SMTP", + "help": "Port keluar (default 587 dengan STARTTLS; 465 untuk SSL implisit)." + }, + "smtp_username": { + "label": "Nama pengguna SMTP", + "help": "Akun untuk autentikasi ke server SMTP, biasanya alamat email lengkap." + }, + "smtp_password": { + "label": "Kata sandi SMTP", + "help": "Kata sandi atau kata sandi aplikasi untuk SMTP. Disimpan di sisi server dan tidak pernah dikirim kembali ke browser." + }, + "smtp_use_tls": { + "label": "STARTTLS SMTP", + "help": "Enkripsi koneksi SMTP dengan STARTTLS (aktif secara default; lazim untuk port 587). Dalam praktik tidak dipakai bersama SSL SMTP." + }, + "smtp_use_ssl": { + "label": "SSL SMTP", + "help": "Hubungkan ke SMTP melalui TLS implisit, misalnya port 465 (nonaktif secara default). Dalam praktik tidak dipakai bersama STARTTLS." + }, + "verify_tls": { + "label": "Verifikasi sertifikat TLS", + "help": "Verifikasi sertifikat TLS dan nama host server email (disarankan). Nonaktifkan hanya untuk server dengan sertifikat self-signed atau CA internal." + }, + "from_address": { + "label": "Alamat pengirim", + "help": "Alamat asal balasan dikirim. Jika kosong, memakai nama pengguna SMTP." + }, + "auto_reply_enabled": { + "label": "Balasan otomatis", + "help": "Membalas email masuk secara otomatis. Pengiriman yang dimulai agen tidak terpengaruh." + }, + "poll_interval_seconds": { + "label": "Interval pemantauan (dtk)", + "help": "Seberapa sering kotak IMAP diperiksa untuk email baru (default 30, minimum 5)." + }, + "mark_seen": { + "label": "Tandai sudah dibaca", + "help": "Tandai email yang sudah diproses sebagai sudah dibaca di kotak surat." + }, + "post_action": { + "label": "Tindakan setelah proses", + "help": "Perlakuan email yang sudah diproses: delete (hapus), move (pindahkan ke folder lain), atau kosongkan untuk menyimpannya." + }, + "post_action_move_mailbox": { + "label": "Pindahkan ke folder", + "help": "Folder tujuan saat tindakan setelah proses adalah move." + }, + "post_action_expunge": { + "label": "Hapus permanen setelah delete", + "help": "Hapus permanen email yang sudah dihapus (expunge), bukan sekadar menandainya." + }, + "post_action_ignore_skipped": { + "label": "Lewati email sendiri dan tersaring", + "help": "Email yang dilewati (milik Anda sendiri dan pengirim yang tersaring daftar) tidak dikenai tindakan setelah proses (aktif secara default)." + }, + "max_body_chars": { + "label": "Maks. karakter isi", + "help": "Potong isi setiap email pada jumlah karakter ini (default 12000)." + }, + "subject_prefix": { + "label": "Prefiks subjek balasan", + "help": "Prefiks yang ditambahkan ke subjek balasan (default \"Re: \")." + }, + "allow_from": { + "label": "Pengirim yang diizinkan", + "help": "Daftar alamat email yang boleh berbicara dengan agen (opsional, \"*\" mengizinkan semua). Pengirim yang belum dipasangkan menerima kode pemasangan melalui balasan." + }, + "verify_dkim": { + "label": "Verifikasi DKIM", + "help": "Wajibkan header Authentication-Results dengan dkim=pass. Menonaktifkan DKIM dan SPF sekaligus akan menerima header From palsu." + }, + "verify_spf": { + "label": "Verifikasi SPF", + "help": "Wajibkan header Authentication-Results dengan spf=pass. Menonaktifkan DKIM dan SPF sekaligus akan menerima header From palsu." + }, + "allowed_attachment_types": { + "label": "Jenis lampiran yang diizinkan", + "help": "Daftar MIME untuk lampiran masuk, misalnya application/pdf atau image/*. Kosongkan untuk menonaktifkan lampiran." + }, + "max_attachment_size": { + "label": "Ukuran maks. lampiran (byte)", + "help": "Ukuran byte terbesar untuk satu lampiran (default 2000000 = 2 MB)." + }, + "max_attachments_per_email": { + "label": "Maks. lampiran per email", + "help": "Berapa banyak lampiran yang diterima dari satu email (default 5)." + } + }, + "websocket": { + "host": { + "label": "Host bind", + "help": "Alamat lokal tempat server WebSocket terikat (default 127.0.0.1, hanya lokal). Host wildcard seperti 0.0.0.0 atau :: mengekspos server di semua antarmuka dan ditolak kecuali token atau rahasia penerbitan token diatur." + }, + "port": { + "label": "Port bind", + "help": "Port lokal yang didengarkan server WebSocket (default 8765). Diabaikan bila path socket Unix diatur." + }, + "unix_socket_path": { + "label": "Path socket Unix", + "help": "Path absolut opsional. Bila diatur, server mendengarkan socket Unix ini alih-alih host dan port." + }, + "path": { + "label": "Path upgrade", + "help": "Path upgrade WebSocket yang dihubungi klien (default /). Harus diawali \"/\"." + }, + "token": { + "label": "Token statis", + "help": "Rahasia bersama yang diperiksa melalui parameter token saat handshake. Disimpan di sisi server dan tidak pernah dikirim kembali ke browser." + }, + "token_issue_path": { + "label": "Path penerbitan token", + "help": "Rute HTTP GET yang menerbitkan token sekali pakai berumur pendek dalam JSON. Harus berbeda dari path upgrade." + }, + "token_issue_secret": { + "label": "Rahasia penerbitan token", + "help": "Rahasia Bearer yang diminta rute penerbitan (Authorization: Bearer atau X-Vibe-Trading-Auth). Disimpan di sisi server dan tidak pernah dikirim kembali ke browser." + }, + "token_ttl_s": { + "label": "TTL token terbitan (dtk)", + "help": "Masa berlaku token yang diterbitkan rute penerbitan, dalam detik (30-86400, default 300)." + }, + "websocket_requires_token": { + "label": "Wajibkan token", + "help": "Tolak handshake tanpa token statis atau token terbitan yang valid (aktif secara default)." + }, + "allow_from": { + "label": "Klien yang diizinkan", + "help": "Daftar ID klien yang boleh terhubung (opsional, \"*\" mengizinkan semua, default)." + }, + "streaming": { + "label": "Streaming keluaran", + "help": "Kirim delta keluaran model ke klien yang terhubung saat dihasilkan." + }, + "max_message_bytes": { + "label": "Maks. byte pesan", + "help": "Frame masuk terbesar yang diterima, dalam byte (default 37748736; 1024-41943040)." + }, + "ping_interval_s": { + "label": "Interval ping (dtk)", + "help": "Detik antar ping keepalive (5-300, default 20)." + }, + "ping_timeout_s": { + "label": "Timeout ping (dtk)", + "help": "Detik menunggu balasan ping sebelum koneksi diputus (5-300, default 20)." + }, + "ssl_certfile": { + "label": "Path sertifikat TLS", + "help": "Path sertifikat PEM di server untuk WSS. Atur sertifikat dan kunci bersamaan, atau kosongkan keduanya; ini path berkas di server, bukan materi yang diunggah." + }, + "ssl_keyfile": { + "label": "Path kunci TLS", + "help": "Path kunci privat PEM di server untuk WSS. Atur sertifikat dan kunci bersamaan, atau kosongkan keduanya; ini path berkas di server, bukan materi yang diunggah." + } } }, "guides": { @@ -547,6 +735,26 @@ "step4": "Tambahkan bot ke grup QQ atau chat dengannya secara pribadi, sesuai aturan sandbox/publikasi platform.", "step5": "Kembali ke sini, klik Uji koneksi, lalu aktifkan channel.", "docsLabel": "Buka QQ Open Platform" + }, + "email": { + "title": "Panduan penyiapan email", + "intro": "Kanal email mengambil email masuk melalui IMAP dan membalas melalui SMTP, jadi tidak perlu URL callback publik.", + "step1": "Dapatkan host dan port IMAP serta SMTP dari penyedia email Anda (biasanya imap. port 993 dan smtp. port 587).", + "step2": "Aktifkan IMAP dan SMTP di pengaturan kotak surat, lalu buat kata sandi aplikasi jika penyedia mensyaratkannya (Gmail, QQ Mail, dan lain-lain dengan verifikasi dua langkah).", + "step3": "Isi kredensial IMAP dan SMTP di atas, atur alamat pengirim, dan tambahkan alamat Anda ke «Pengirim yang diizinkan».", + "step4": "Klik «Uji koneksi»; pengujian memeriksa login IMAP, folder kotak surat, dan login SMTP.", + "step5": "Centang «Persetujuan diberikan» lalu aktifkan kanal; balasan otomatis akan sampai ke pengirim yang lolos pemeriksaan DKIM/SPF.", + "docsLabel": "Buka dokumentasi Vibe-Trading" + }, + "websocket": { + "title": "Panduan penyiapan WebSocket", + "intro": "Kanal WebSocket menjalankan server lokalnya sendiri yang dihubungi klien melalui URL WebSocket, jadi tidak perlu URL callback publik.", + "step1": "Pilih alamat bind: biarkan 127.0.0.1 untuk penggunaan lokal saja, atau atur 0.0.0.0 / :: untuk mengekspos server — host wildcard mewajibkan token atau rahasia penerbitan token.", + "step2": "Atur token statis, atau path penerbitan token beserta rahasia Bearer-nya agar klien bisa meminta token berumur pendek.", + "step3": "Hubungkan klien dengan ws://host:port/path?client_id=...&token=...", + "step4": "Menyimpan saat kanal berjalan akan menukar server secara panas: klien yang terhubung (termasuk obrolan Web UI ini) sempat terputus lalu tersambung kembali.", + "step5": "Klik «Uji koneksi»; pengujian memvalidasi materi sertifikat/kunci SSL dan ketersediaan alamat secara lokal.", + "docsLabel": "Buka dokumentasi Vibe-Trading" } } }, diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index 5a2532c1..14f1e67e 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -520,6 +520,194 @@ "label": "最大ダウンロードサイズ(バイト)", "help": "1 つの添付ファイルのダウンロードに対する安全上限(既定 209715200=200 MB)。" } + }, + "email": { + "consent_granted": { + "label": "同意済み", + "help": "マスター許可スイッチ。ここで明示的に同意するまで、このチャンネルはメールを読み取り・送信しません。" + }, + "imap_host": { + "label": "IMAP ホスト", + "help": "受信メールサーバーのホスト名(例: imap.gmail.com)。新着メールはこのサーバーから IMAP でポーリングします。" + }, + "imap_port": { + "label": "IMAP ポート", + "help": "受信ポート(既定 993、SSL 使用)。" + }, + "imap_username": { + "label": "IMAP ユーザー名", + "help": "IMAP のログインに使うメールボックスアカウント。通常はメールアドレス全体です。" + }, + "imap_password": { + "label": "IMAP パスワード", + "help": "IMAP 用のメールボックスパスワードまたはアプリパスワード。サーバー側にのみ保存され、ブラウザーには返されません。" + }, + "imap_mailbox": { + "label": "IMAP メールボックス", + "help": "新着メールをポーリングするフォルダー(既定 INBOX)。" + }, + "imap_use_ssl": { + "label": "IMAP SSL", + "help": "SSL 経由で IMAP に接続します(既定オン。ポート 993 では必須)。" + }, + "smtp_host": { + "label": "SMTP ホスト", + "help": "送信メールサーバーのホスト名(例: smtp.gmail.com)。返信はこのサーバー経由で送信されます。" + }, + "smtp_port": { + "label": "SMTP ポート", + "help": "送信ポート(既定 587、STARTTLS 使用。465 は暗黙的 SSL 用)。" + }, + "smtp_username": { + "label": "SMTP ユーザー名", + "help": "SMTP 認証に使うアカウント。通常はメールアドレス全体です。" + }, + "smtp_password": { + "label": "SMTP パスワード", + "help": "SMTP 用のパスワードまたはアプリパスワード。サーバー側にのみ保存され、ブラウザーには返されません。" + }, + "smtp_use_tls": { + "label": "SMTP STARTTLS", + "help": "STARTTLS で SMTP 接続を暗号化します(既定オン。ポート 587 の標準)。実際には SMTP SSL と併用しません。" + }, + "smtp_use_ssl": { + "label": "SMTP SSL", + "help": "暗黙的 TLS で SMTP に接続します(例: ポート 465、既定オフ)。実際には STARTTLS と併用しません。" + }, + "verify_tls": { + "label": "TLS 証明書を検証", + "help": "メールサーバーの TLS 証明書とホスト名を検証します(推奨)。自己署名または社内 CA のサーバーの場合のみ無効にしてください。" + }, + "from_address": { + "label": "送信元アドレス", + "help": "返信メールの送信元アドレス。空欄の場合は SMTP ユーザー名を使用します。" + }, + "auto_reply_enabled": { + "label": "自動返信", + "help": "受信メールに自動で返信します。エージェントが主体的に行う送信には影響しません。" + }, + "poll_interval_seconds": { + "label": "ポーリング間隔(秒)", + "help": "IMAP で新着メールを確認する頻度(既定 30、最小 5)。" + }, + "mark_seen": { + "label": "既読にする", + "help": "処理済みのメールをメールボックスで既読にします。" + }, + "post_action": { + "label": "処理後の動作", + "help": "処理済みメールの扱い: delete(削除)、move(別フォルダーへ移動)、空欄ならそのまま保持。" + }, + "post_action_move_mailbox": { + "label": "移動先メールボックス", + "help": "「処理後の動作」で move を選んだ場合の移動先フォルダー。" + }, + "post_action_expunge": { + "label": "削除後に expunge", + "help": "削除したメールに削除フラグを残さず、完全に消去します。" + }, + "post_action_ignore_skipped": { + "label": "自分と除外メールは対象外", + "help": "スキップされたメール(自分のメールや許可リストで除外された送信者)には処理後の動作を適用しません(既定オン)。" + }, + "max_body_chars": { + "label": "本文の最大文字数", + "help": "メール本文をこの文字数で切り詰めます(既定 12000)。" + }, + "subject_prefix": { + "label": "返信の件名プレフィックス", + "help": "返信の件名に付けるプレフィックス(既定 \"Re: \")。" + }, + "allow_from": { + "label": "許可する送信者", + "help": "エージェントと対話できるメールアドレスの許可リスト(任意、\"*\" で全員を許可)。未ペアリングの送信者には返信でペアリングコードが届きます。" + }, + "verify_dkim": { + "label": "DKIM を検証", + "help": "Authentication-Results ヘッダーに dkim=pass を要求します。DKIM と SPF の両方を無効にすると、偽装された From ヘッダーを受け入れます。" + }, + "verify_spf": { + "label": "SPF を検証", + "help": "Authentication-Results ヘッダーに spf=pass を要求します。DKIM と SPF の両方を無効にすると、偽装された From ヘッダーを受け入れます。" + }, + "allowed_attachment_types": { + "label": "許可する添付タイプ", + "help": "受信添付ファイルの MIME 許可リスト(例: application/pdf、image/*)。空欄にすると添付が無効になります。" + }, + "max_attachment_size": { + "label": "添付サイズ上限(バイト)", + "help": "1 件の添付ファイルの最大バイト数(既定 2000000 = 2 MB)。" + }, + "max_attachments_per_email": { + "label": "1 通あたりの添付数上限", + "help": "1 通のメールから受け入れる添付ファイルの最大数(既定 5)。" + } + }, + "websocket": { + "host": { + "label": "バインドホスト", + "help": "WebSocket サーバーがバインドするローカルアドレス(既定 127.0.0.1、ローカル専用)。0.0.0.0 や :: などのワイルドカードホストは全インターフェースに公開されるため、トークンまたはトークン発行シークレットがないと拒否されます。" + }, + "port": { + "label": "バインドポート", + "help": "WebSocket サーバーが待ち受けるローカルポート(既定 8765)。Unix ソケットパスを設定した場合は無視されます。" + }, + "unix_socket_path": { + "label": "Unix ソケットパス", + "help": "任意の絶対パス。設定すると、ホストとポートの代わりにこの Unix ソケットで待ち受けます。" + }, + "path": { + "label": "アップグレードパス", + "help": "クライアントが接続する WebSocket アップグレードパス(既定 /)。「/」で始まる必要があります。" + }, + "token": { + "label": "静的トークン", + "help": "ハンドシェイク時に token クエリパラメーターで照合する共有シークレット。サーバー側にのみ保存され、ブラウザーには返されません。" + }, + "token_issue_path": { + "label": "トークン発行パス", + "help": "短期のワンタイムトークンを JSON で返す HTTP GET ルート。アップグレードパスとは別の値にする必要があります。" + }, + "token_issue_secret": { + "label": "トークン発行シークレット", + "help": "トークン発行ルートが要求する Bearer シークレット(Authorization: Bearer または X-Vibe-Trading-Auth)。サーバー側にのみ保存され、ブラウザーには返されません。" + }, + "token_ttl_s": { + "label": "発行トークンの TTL(秒)", + "help": "トークン発行ルートが発行するトークンの有効期間(秒、30〜86400、既定 300)。" + }, + "websocket_requires_token": { + "label": "トークンを必須にする", + "help": "有効な静的トークンまたは発行済みトークンを持たないハンドシェイクを拒否します(既定オン)。" + }, + "allow_from": { + "label": "許可するクライアント", + "help": "接続を許可するクライアント ID の許可リスト(任意、\"*\" で全員を許可、既定)。" + }, + "streaming": { + "label": "出力をストリーミング", + "help": "モデル出力の差分を、生成されるそばから接続中のクライアントへ配信します。" + }, + "max_message_bytes": { + "label": "メッセージ上限(バイト)", + "help": "受け入れる受信フレームの最大バイト数(既定 37748736、範囲 1024〜41943040)。" + }, + "ping_interval_s": { + "label": "Ping 間隔(秒)", + "help": "キープアライブ ping を送る間隔(5〜300、既定 20)。" + }, + "ping_timeout_s": { + "label": "Ping タイムアウト(秒)", + "help": "ping 応答を待つ秒数。超えると接続を切断します(5〜300、既定 20)。" + }, + "ssl_certfile": { + "label": "TLS 証明書パス", + "help": "WSS 用のサーバー上の PEM 証明書パス。証明書と鍵は両方を設定するか、両方空にしてください。アップロード素材ではなくサーバー上のファイルパスです。" + }, + "ssl_keyfile": { + "label": "TLS 鍵パス", + "help": "WSS 用のサーバー上の PEM 秘密鍵パス。証明書と鍵は両方を設定するか、両方空にしてください。アップロード素材ではなくサーバー上のファイルパスです。" + } } }, "guides": { @@ -542,6 +730,26 @@ "step4": "プラットフォームのサンドボックス/公開ルールに従って、ボットを QQ グループに追加するか、個別にチャットします。", "step5": "ここに戻って「接続テスト」をクリックし、チャンネルを有効化します。", "docsLabel": "QQ 公式プラットフォームを開く" + }, + "email": { + "title": "メール設定ガイド", + "intro": "メールチャンネルは受信を IMAP ポーリング、返信を SMTP で行うため、公開コールバック URL は不要です。", + "step1": "メールプロバイダーの IMAP / SMTP のホスト名とポートを確認します(通常は imap.<プロバイダー> の 993 と smtp.<プロバイダー> の 587)。", + "step2": "メールボックスの設定で IMAP と SMTP を有効にし、プロバイダーが求める場合(Gmail、QQ メールなど 2 段階認証を使う場合)はアプリパスワードを作成します。", + "step3": "上の IMAP / SMTP 認証情報を入力し、送信元アドレスを設定して、自分のアドレスを「許可する送信者」に追加します。", + "step4": "「接続テスト」をクリックします。IMAP ログイン、メールボックスフォルダー、SMTP ログインの順に確認されます。", + "step5": "「同意済み」にチェックを入れてチャンネルを有効にすると、DKIM/SPF 検証を通過した送信者に自動返信が届きます。", + "docsLabel": "Vibe-Trading のドキュメントを開く" + }, + "websocket": { + "title": "WebSocket 設定ガイド", + "intro": "WebSocket チャンネルはローカルサーバーを起動し、クライアントが WebSocket URL で直接接続するため、公開コールバック URL は不要です。", + "step1": "バインドアドレスを選びます。ローカル専用なら 127.0.0.1 のまま、外部に公開するなら 0.0.0.0 または :: を設定します(ワイルドカードホストにはトークンまたはトークン発行シークレットが必須です)。", + "step2": "静的トークンを設定するか、トークン発行パスとその Bearer シークレットを設定して、クライアントが短期トークンを取得できるようにします。", + "step3": "クライアントは ws://host:port/path?client_id=...&token=... で接続します。", + "step4": "チャンネル実行中に保存するとサーバーがホットスワップされます。接続中のクライアント(この Web UI チャットを含む)は一時的に切断され、自動的に再接続します。", + "step5": "「接続テスト」をクリックすると、SSL 証明書/鍵の素材とアドレスの可用性をローカルで検証します。", + "docsLabel": "Vibe-Trading のドキュメントを開く" } } }, diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index dfc67985..9a717ee7 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -520,6 +520,194 @@ "label": "최대 다운로드 크기(바이트)", "help": "첨부 파일 하나 다운로드의 안전 상한(기본 209715200 = 200 MB)." } + }, + "email": { + "consent_granted": { + "label": "동의 승인", + "help": "최상위 허용 스위치입니다. 여기서 명시적으로 동의해야만 이 채널이 메일을 읽고 보냅니다." + }, + "imap_host": { + "label": "IMAP 호스트", + "help": "수신 메일 서버 호스트입니다(예: imap.gmail.com). 새 메일은 이 서버에서 IMAP으로 폴링합니다." + }, + "imap_port": { + "label": "IMAP 포트", + "help": "수신 포트입니다(기본 993, SSL 사용)." + }, + "imap_username": { + "label": "IMAP 사용자 이름", + "help": "IMAP 로그인에 사용하는 메일함 계정으로, 보통 전체 이메일 주소입니다." + }, + "imap_password": { + "label": "IMAP 비밀번호", + "help": "IMAP용 메일함 비밀번호 또는 앱 비밀번호입니다. 서버 측에만 저장되며 브라우저로 반환되지 않습니다." + }, + "imap_mailbox": { + "label": "IMAP 메일함", + "help": "새 메일을 폴링할 폴더입니다(기본 INBOX)." + }, + "imap_use_ssl": { + "label": "IMAP SSL", + "help": "SSL로 IMAP에 연결합니다(기본 켜짐, 포트 993에 필요)." + }, + "smtp_host": { + "label": "SMTP 호스트", + "help": "발신 메일 서버 호스트입니다(예: smtp.gmail.com). 답장은 이 서버를 통해 전송됩니다." + }, + "smtp_port": { + "label": "SMTP 포트", + "help": "발신 포트입니다(기본 587, STARTTLS 사용. 465는 암시적 SSL)." + }, + "smtp_username": { + "label": "SMTP 사용자 이름", + "help": "SMTP 인증에 사용하는 계정으로, 보통 전체 이메일 주소입니다." + }, + "smtp_password": { + "label": "SMTP 비밀번호", + "help": "SMTP용 비밀번호 또는 앱 비밀번호입니다. 서버 측에만 저장되며 브라우저로 반환되지 않습니다." + }, + "smtp_use_tls": { + "label": "SMTP STARTTLS", + "help": "STARTTLS로 SMTP 연결을 암호화합니다(기본 켜짐, 포트 587의 표준). 실제로는 SMTP SSL과 함께 사용하지 않습니다." + }, + "smtp_use_ssl": { + "label": "SMTP SSL", + "help": "암시적 TLS로 SMTP에 연결합니다(예: 포트 465, 기본 꺼짐). 실제로는 STARTTLS와 함께 사용하지 않습니다." + }, + "verify_tls": { + "label": "TLS 인증서 검증", + "help": "메일 서버의 TLS 인증서와 호스트 이름을 검증합니다(권장). 자체 서명 또는 내부 CA 서버인 경우에만 끄세요." + }, + "from_address": { + "label": "보낸 사람 주소", + "help": "답장을 보낼 주소입니다. 비워 두면 SMTP 사용자 이름을 사용합니다." + }, + "auto_reply_enabled": { + "label": "자동 답장", + "help": "수신 메일에 자동으로 답장합니다. 에이전트가 시작하는 발신에는 영향을 주지 않습니다." + }, + "poll_interval_seconds": { + "label": "폴링 간격(초)", + "help": "IMAP 메일함에서 새 메일을 확인하는 주기입니다(기본 30, 최소 5)." + }, + "mark_seen": { + "label": "읽음으로 표시", + "help": "처리한 메일을 메일함에서 읽음으로 표시합니다." + }, + "post_action": { + "label": "처리 후 동작", + "help": "처리한 메일의 처리 방식: delete(삭제), move(다른 메일함으로 이동), 비워 두면 그대로 유지." + }, + "post_action_move_mailbox": { + "label": "이동할 메일함", + "help": "‘처리 후 동작’이 move일 때 사용할 대상 메일함입니다." + }, + "post_action_expunge": { + "label": "삭제 후 expunge", + "help": "삭제한 메일에 삭제 플래그를 남기지 않고 완전히 제거합니다." + }, + "post_action_ignore_skipped": { + "label": "본인·필터 메일 제외", + "help": "건너뛴 메일(본인이 보낸 메일과 허용 목록에서 걸러진 발신자)에는 처리 후 동작을 적용하지 않습니다(기본 켜짐)." + }, + "max_body_chars": { + "label": "본문 최대 문자 수", + "help": "메일 본문을 이 문자 수로 잘라냅니다(기본 12000)." + }, + "subject_prefix": { + "label": "답장 제목 접두사", + "help": "답장 제목 앞에 붙일 접두사입니다(기본 \"Re: \")." + }, + "allow_from": { + "label": "허용 발신자", + "help": "에이전트와 대화할 수 있는 이메일 주소 허용 목록입니다(선택, \"*\"는 모든 사람 허용). 페어링되지 않은 발신자에게는 답장으로 페어링 코드가 전송됩니다." + }, + "verify_dkim": { + "label": "DKIM 검증", + "help": "Authentication-Results 헤더에 dkim=pass를 요구합니다. DKIM과 SPF를 모두 끄면 위조된 From 헤더를 허용하게 됩니다." + }, + "verify_spf": { + "label": "SPF 검증", + "help": "Authentication-Results 헤더에 spf=pass를 요구합니다. DKIM과 SPF를 모두 끄면 위조된 From 헤더를 허용하게 됩니다." + }, + "allowed_attachment_types": { + "label": "허용 첨부 유형", + "help": "수신 첨부 파일의 MIME 허용 목록입니다(예: application/pdf, image/*). 비워 두면 첨부가 비활성화됩니다." + }, + "max_attachment_size": { + "label": "첨부 크기 상한(바이트)", + "help": "첨부 파일 하나의 최대 바이트 수입니다(기본 2000000 = 2 MB)." + }, + "max_attachments_per_email": { + "label": "메일당 첨부 수 상한", + "help": "메일 한 통에서 허용하는 첨부 파일 수입니다(기본 5)." + } + }, + "websocket": { + "host": { + "label": "바인드 호스트", + "help": "WebSocket 서버가 바인드할 로컬 주소입니다(기본 127.0.0.1, 로컬 전용). 0.0.0.0 또는 :: 같은 와일드카드 호스트는 모든 인터페이스에 서버를 노출하므로 토큰이나 토큰 발급 시크릿이 없으면 거부됩니다." + }, + "port": { + "label": "바인드 포트", + "help": "WebSocket 서버가 수신 대기할 로컬 포트입니다(기본 8765). Unix 소켓 경로를 설정하면 무시됩니다." + }, + "unix_socket_path": { + "label": "Unix 소켓 경로", + "help": "선택적 절대 경로입니다. 설정하면 호스트와 포트 대신 이 Unix 소켓에서 수신 대기합니다." + }, + "path": { + "label": "업그레이드 경로", + "help": "클라이언트가 연결하는 WebSocket 업그레이드 경로입니다(기본 /). \"/\"로 시작해야 합니다." + }, + "token": { + "label": "정적 토큰", + "help": "핸드셰이크 시 token 쿼리 파라미터로 확인하는 공유 시크릿입니다. 서버 측에만 저장되며 브라우저로 반환되지 않습니다." + }, + "token_issue_path": { + "label": "토큰 발급 경로", + "help": "단기 일회용 토큰을 JSON으로 발급하는 HTTP GET 경로입니다. 업그레이드 경로와 달라야 합니다." + }, + "token_issue_secret": { + "label": "토큰 발급 시크릿", + "help": "토큰 발급 경로가 요구하는 Bearer 시크릿입니다(Authorization: Bearer 또는 X-Vibe-Trading-Auth). 서버 측에만 저장되며 브라우저로 반환되지 않습니다." + }, + "token_ttl_s": { + "label": "발급 토큰 TTL(초)", + "help": "토큰 발급 경로가 발급한 토큰의 유효 시간(초)입니다(30–86400, 기본 300)." + }, + "websocket_requires_token": { + "label": "토큰 필수", + "help": "유효한 정적 토큰이나 발급된 토큰이 없는 핸드셰이크를 거부합니다(기본 켜짐)." + }, + "allow_from": { + "label": "허용 클라이언트", + "help": "연결을 허용할 클라이언트 ID 허용 목록입니다(선택, \"*\"는 모든 클라이언트 허용, 기본)." + }, + "streaming": { + "label": "출력 스트리밍", + "help": "모델 출력 델타를 생성되는 즉시 연결된 클라이언트로 전송합니다." + }, + "max_message_bytes": { + "label": "메시지 상한(바이트)", + "help": "허용하는 수신 프레임의 최대 바이트 수입니다(기본 37748736, 범위 1024–41943040)." + }, + "ping_interval_s": { + "label": "Ping 간격(초)", + "help": "연결 유지 ping을 보내는 간격(초)입니다(5–300, 기본 20)." + }, + "ping_timeout_s": { + "label": "Ping 타임아웃(초)", + "help": "ping 응답을 기다리는 시간(초)입니다. 초과하면 연결을 끊습니다(5–300, 기본 20)." + }, + "ssl_certfile": { + "label": "TLS 인증서 경로", + "help": "WSS용 서버의 PEM 인증서 경로입니다. 인증서와 키를 모두 설정하거나 모두 비워 두세요. 업로드 자료가 아니라 서버의 파일 경로입니다." + }, + "ssl_keyfile": { + "label": "TLS 키 경로", + "help": "WSS용 서버의 PEM 개인 키 경로입니다. 인증서와 키를 모두 설정하거나 모두 비워 두세요. 업로드 자료가 아니라 서버의 파일 경로입니다." + } } }, "guides": { @@ -542,6 +730,26 @@ "step4": "플랫폼의 샌드박스/게시 규칙에 따라 봇을 QQ 그룹에 추가하거나 개인 채팅을 시작합니다.", "step5": "여기로 돌아와 '연결 테스트'를 클릭한 다음 채널을 사용 설정합니다.", "docsLabel": "QQ 오픈 플랫폼 열기" + }, + "email": { + "title": "이메일 설정 가이드", + "intro": "이메일 채널은 수신은 IMAP 폴링, 답장은 SMTP로 처리하므로 공개 콜백 URL이 필요 없습니다.", + "step1": "메일 제공업체의 IMAP/SMTP 호스트와 포트를 확인합니다(보통 imap.<제공업체>의 993, smtp.<제공업체>의 587).", + "step2": "메일함 설정에서 IMAP과 SMTP를 활성화하고, 제공업체가 요구하면(Gmail, QQ 메일 등 2단계 인증 사용 시) 앱 비밀번호를 만듭니다.", + "step3": "위의 IMAP/SMTP 자격 증명을 입력하고 보낸 사람 주소를 설정한 뒤, 본인 주소를 ‘허용 발신자’에 추가합니다.", + "step4": "‘연결 테스트’를 클릭합니다. IMAP 로그인, 메일함 폴더, SMTP 로그인을 차례로 점검합니다.", + "step5": "‘동의 승인’을 체크하고 채널을 활성화하면 DKIM/SPF 검증을 통과한 발신자에게 자동 답장이 전달됩니다.", + "docsLabel": "Vibe-Trading 문서 열기" + }, + "websocket": { + "title": "WebSocket 설정 가이드", + "intro": "WebSocket 채널은 자체 로컬 서버를 실행하고 클라이언트가 WebSocket 주소로 직접 연결하므로 공개 콜백 URL이 필요 없습니다.", + "step1": "바인드 주소를 선택합니다. 로컬 전용이면 127.0.0.1을 유지하고, 외부에 노출하려면 0.0.0.0 또는 ::를 설정합니다(와일드카드 호스트에는 토큰 또는 토큰 발급 시크릿이 필수).", + "step2": "정적 토큰을 설정하거나, 토큰 발급 경로와 Bearer 시크릿을 설정해 클라이언트가 단기 토큰을 받도록 합니다.", + "step3": "클라이언트는 ws://host:port/path?client_id=...&token=... 으로 연결합니다.", + "step4": "채널 실행 중에 저장하면 서버가 핫 스왑됩니다. 연결된 클라이언트(이 웹 UI 채팅 포함)는 잠시 끊겼다가 자동으로 다시 연결됩니다.", + "step5": "‘연결 테스트’를 클릭하면 SSL 인증서/키 자료와 주소 사용 가능 여부를 로컬에서 검증합니다.", + "docsLabel": "Vibe-Trading 문서 열기" } } }, diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index c55fe6f4..65db87f1 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -525,6 +525,194 @@ "label": "Tamanho máximo de download (bytes)", "help": "Limite de segurança para o download de um único anexo (padrão 209715200 = 200 MB)." } + }, + "email": { + "consent_granted": { + "label": "Consentimento concedido", + "help": "Chave de permissão principal. O canal só lê e envia e-mails depois que você concede o consentimento explicitamente." + }, + "imap_host": { + "label": "Host IMAP", + "help": "Servidor de e-mail de entrada, por exemplo imap.gmail.com. Os e-mails recebidos são consultados nesse servidor via IMAP." + }, + "imap_port": { + "label": "Porta IMAP", + "help": "Porta de entrada (padrão 993 com SSL)." + }, + "imap_username": { + "label": "Usuário IMAP", + "help": "Conta da caixa de correio usada para entrar via IMAP; normalmente é o endereço de e-mail completo." + }, + "imap_password": { + "label": "Senha IMAP", + "help": "Senha da caixa de correio ou senha de aplicativo para IMAP. Fica armazenada no servidor e nunca é devolvida ao navegador." + }, + "imap_mailbox": { + "label": "Caixa IMAP", + "help": "Pasta consultada em busca de e-mails novos (padrão INBOX)." + }, + "imap_use_ssl": { + "label": "SSL do IMAP", + "help": "Conectar ao IMAP via SSL (ativado por padrão; necessário para a porta 993)." + }, + "smtp_host": { + "label": "Host SMTP", + "help": "Servidor de e-mail de saída, por exemplo smtp.gmail.com. As respostas são enviadas por esse servidor." + }, + "smtp_port": { + "label": "Porta SMTP", + "help": "Porta de saída (padrão 587 com STARTTLS; 465 para SSL implícito)." + }, + "smtp_username": { + "label": "Usuário SMTP", + "help": "Conta usada para autenticar no servidor SMTP; normalmente é o endereço de e-mail completo." + }, + "smtp_password": { + "label": "Senha SMTP", + "help": "Senha ou senha de aplicativo para SMTP. Fica armazenada no servidor e nunca é devolvida ao navegador." + }, + "smtp_use_tls": { + "label": "STARTTLS do SMTP", + "help": "Criptografar a conexão SMTP com STARTTLS (ativado por padrão; o usual na porta 587). Na prática, é exclusivo com o SSL do SMTP." + }, + "smtp_use_ssl": { + "label": "SSL do SMTP", + "help": "Conectar ao SMTP via TLS implícito, por exemplo na porta 465 (desativado por padrão). Na prática, é exclusivo com o STARTTLS." + }, + "verify_tls": { + "label": "Verificar o certificado TLS", + "help": "Verificar o certificado TLS e o nome de host do servidor de e-mail (recomendado). Desative apenas para servidores com certificado autoassinado ou CA interna." + }, + "from_address": { + "label": "Endereço do remetente", + "help": "Endereço de onde as respostas são enviadas. Se ficar vazio, usa o usuário SMTP." + }, + "auto_reply_enabled": { + "label": "Respostas automáticas", + "help": "Responder automaticamente aos e-mails recebidos. Envios iniciados pelo agente não são afetados." + }, + "poll_interval_seconds": { + "label": "Intervalo de consulta (s)", + "help": "Com que frequência a caixa IMAP é verificada em busca de e-mails novos (padrão 30, mínimo 5)." + }, + "mark_seen": { + "label": "Marcar como lido", + "help": "Marcar como lidos os e-mails processados na caixa de correio." + }, + "post_action": { + "label": "Ação após processar", + "help": "O que fazer com os e-mails processados: delete (excluir), move (mover para outra pasta) ou vazio para mantê-los." + }, + "post_action_move_mailbox": { + "label": "Mover para a pasta", + "help": "Pasta de destino usada quando a ação após processar é move." + }, + "post_action_expunge": { + "label": "Expurgar após excluir", + "help": "Remover definitivamente os e-mails excluídos (expunge) em vez de deixá-los apenas sinalizados." + }, + "post_action_ignore_skipped": { + "label": "Ignorar próprios e filtrados", + "help": "Não aplicar a ação pós-processamento aos e-mails ignorados (os seus e os de remetentes filtrados pela lista) (ativado por padrão)." + }, + "max_body_chars": { + "label": "Máx. de caracteres do corpo", + "help": "Truncar o corpo de cada e-mail nesse número de caracteres (padrão 12000)." + }, + "subject_prefix": { + "label": "Prefixo do assunto", + "help": "Prefixo adicionado ao assunto das respostas (padrão \"Re: \")." + }, + "allow_from": { + "label": "Remetentes permitidos", + "help": "Lista opcional de endereços autorizados a falar com o agente (\"*\" permite todos). Remetentes não pareados recebem um código de pareamento na resposta." + }, + "verify_dkim": { + "label": "Verificar DKIM", + "help": "Exigir o cabeçalho Authentication-Results com dkim=pass. Desativar DKIM e SPF ao mesmo tempo aceita cabeçalhos From falsificados." + }, + "verify_spf": { + "label": "Verificar SPF", + "help": "Exigir o cabeçalho Authentication-Results com spf=pass. Desativar DKIM e SPF ao mesmo tempo aceita cabeçalhos From falsificados." + }, + "allowed_attachment_types": { + "label": "Tipos de anexo permitidos", + "help": "Lista MIME permitida para anexos recebidos, por exemplo application/pdf ou image/*. Deixe vazio para desativar anexos." + }, + "max_attachment_size": { + "label": "Tamanho máx. do anexo (bytes)", + "help": "Maior tamanho aceito por anexo, em bytes (padrão 2000000 = 2 MB)." + }, + "max_attachments_per_email": { + "label": "Máx. de anexos por e-mail", + "help": "Quantos anexos são aceitos em um único e-mail (padrão 5)." + } + }, + "websocket": { + "host": { + "label": "Host de escuta", + "help": "Endereço local ao qual o servidor WebSocket se vincula (padrão 127.0.0.1, apenas local). Um host curinga como 0.0.0.0 ou :: expõe o servidor em todas as interfaces e é recusado sem um token ou segredo de emissão." + }, + "port": { + "label": "Porta de escuta", + "help": "Porta local em que o servidor WebSocket escuta (padrão 8765). Ignorada quando um caminho de socket Unix é definido." + }, + "unix_socket_path": { + "label": "Caminho do socket Unix", + "help": "Caminho absoluto opcional. Quando definido, o servidor escuta nesse socket Unix em vez do host e da porta." + }, + "path": { + "label": "Caminho de upgrade", + "help": "Caminho de upgrade WebSocket usado pelos clientes (padrão /). Precisa começar com \"/\"." + }, + "token": { + "label": "Token estático", + "help": "Segredo compartilhado validado pelo parâmetro token durante o handshake. Fica armazenado no servidor e nunca é devolvido ao navegador." + }, + "token_issue_path": { + "label": "Caminho de emissão de tokens", + "help": "Rota HTTP GET que entrega tokens de curta duração e uso único em JSON. Precisa ser diferente do caminho de upgrade." + }, + "token_issue_secret": { + "label": "Segredo de emissão", + "help": "Segredo Bearer exigido pela rota de emissão (Authorization: Bearer ou X-Vibe-Trading-Auth). Fica armazenado no servidor e nunca é devolvido ao navegador." + }, + "token_ttl_s": { + "label": "TTL do token emitido (s)", + "help": "Validade do token emitido pela rota de emissão, em segundos (30-86400, padrão 300)." + }, + "websocket_requires_token": { + "label": "Exigir token", + "help": "Recusar handshakes sem um token estático ou emitido válido (ativado por padrão)." + }, + "allow_from": { + "label": "Clientes permitidos", + "help": "Lista opcional de IDs de cliente autorizados a conectar (\"*\" permite todos, padrão)." + }, + "streaming": { + "label": "Transmitir saída", + "help": "Enviar os deltas de saída do modelo aos clientes conectados conforme são gerados." + }, + "max_message_bytes": { + "label": "Máx. de bytes por mensagem", + "help": "Maior frame de entrada aceito, em bytes (padrão 37748736; 1024-41943040)." + }, + "ping_interval_s": { + "label": "Intervalo de ping (s)", + "help": "Segundos entre pings de keepalive (5-300, padrão 20)." + }, + "ping_timeout_s": { + "label": "Timeout de ping (s)", + "help": "Segundos de espera por uma resposta de ping antes de encerrar a conexão (5-300, padrão 20)." + }, + "ssl_certfile": { + "label": "Caminho do certificado TLS", + "help": "Caminho do certificado PEM no servidor para WSS. Defina certificado e chave juntos ou nenhum dos dois; são caminhos de arquivo no servidor, não material enviado por upload." + }, + "ssl_keyfile": { + "label": "Caminho da chave TLS", + "help": "Caminho da chave privada PEM no servidor para WSS. Defina certificado e chave juntos ou nenhum dos dois; são caminhos de arquivo no servidor, não material enviado por upload." + } } }, "guides": { @@ -547,6 +735,26 @@ "step4": "Adicione o bot a um grupo do QQ ou converse com ele em privado, seguindo as regras de sandbox/publicação da plataforma.", "step5": "Volte aqui, clique em “Testar conexão” e ative o canal.", "docsLabel": "Abrir a QQ Open Platform" + }, + "email": { + "title": "Guia de configuração de e-mail", + "intro": "O canal de e-mail consulta a caixa via IMAP e responde via SMTP, então não é necessária uma URL de callback pública.", + "step1": "Descubra os hosts e as portas de IMAP e SMTP do seu provedor (normalmente imap. na 993 e smtp. na 587).", + "step2": "Ative IMAP e SMTP nas configurações da caixa de correio e, se o provedor exigir (Gmail, QQ Mail e outros com verificação em duas etapas), crie uma senha de aplicativo.", + "step3": "Preencha as credenciais de IMAP e SMTP acima, defina o endereço do remetente e adicione o seu endereço em «Remetentes permitidos».", + "step4": "Clique em «Testar conexão»: ele verifica o login IMAP, a pasta da caixa e o login SMTP.", + "step5": "Marque «Consentimento concedido» e ative o canal; as respostas automáticas passam a chegar aos remetentes que passarem nas verificações DKIM/SPF.", + "docsLabel": "Abrir a documentação do Vibe-Trading" + }, + "websocket": { + "title": "Guia de configuração do WebSocket", + "intro": "O canal WebSocket executa seu próprio servidor local ao qual os clientes se conectam por uma URL WebSocket, então não é necessária uma URL de callback pública.", + "step1": "Escolha um endereço de escuta: mantenha 127.0.0.1 para uso apenas local ou defina 0.0.0.0 / :: para expor o servidor — um host curinga exige um token ou um segredo de emissão.", + "step2": "Defina um token estático, ou um caminho de emissão com seu segredo Bearer para que os clientes obtenham tokens de curta duração.", + "step3": "Conecte os clientes com ws://host:port/path?client_id=...&token=...", + "step4": "Salvar com o canal em execução troca o servidor a quente: os clientes conectados (incluindo este chat da interface web) caem por um instante e se reconectam.", + "step5": "Clique em «Testar conexão»: ele valida localmente o certificado e a chave SSL e a disponibilidade do endereço.", + "docsLabel": "Abrir a documentação do Vibe-Trading" } } }, diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 2b04cf62..3afd3f4a 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -520,6 +520,194 @@ "label": "最大下载大小(字节)", "help": "单个附件下载的安全上限(默认 209715200,即 200 MB)。" } + }, + "email": { + "consent_granted": { + "label": "已授予同意", + "help": "总开关:只有明确授予同意后,该渠道才会读取和发送邮件。" + }, + "imap_host": { + "label": "IMAP 服务器", + "help": "收件(入站)邮件服务器地址,例如 imap.gmail.com。新邮件通过 IMAP 从该服务器轮询。" + }, + "imap_port": { + "label": "IMAP 端口", + "help": "入站端口(默认 993,配合 SSL)。" + }, + "imap_username": { + "label": "IMAP 用户名", + "help": "用于登录 IMAP 的邮箱账号,通常是完整的邮箱地址。" + }, + "imap_password": { + "label": "IMAP 密码", + "help": "IMAP 邮箱密码或应用专用密码。仅保存在服务端,不会回传给浏览器。" + }, + "imap_mailbox": { + "label": "IMAP 邮箱文件夹", + "help": "轮询新邮件所在的文件夹(默认 INBOX)。" + }, + "imap_use_ssl": { + "label": "IMAP SSL", + "help": "通过 SSL 连接 IMAP(默认开启;993 端口需要)。" + }, + "smtp_host": { + "label": "SMTP 服务器", + "help": "发件(出站)邮件服务器地址,例如 smtp.gmail.com。回复邮件经由该服务器发送。" + }, + "smtp_port": { + "label": "SMTP 端口", + "help": "出站端口(默认 587,使用 STARTTLS;465 用于隐式 SSL)。" + }, + "smtp_username": { + "label": "SMTP 用户名", + "help": "用于 SMTP 认证的账号,通常是完整的邮箱地址。" + }, + "smtp_password": { + "label": "SMTP 密码", + "help": "SMTP 密码或应用专用密码。仅保存在服务端,不会回传给浏览器。" + }, + "smtp_use_tls": { + "label": "SMTP STARTTLS", + "help": "使用 STARTTLS 升级 SMTP 连接(默认开启,适用于 587 端口)。实际使用中与 SMTP SSL 互斥。" + }, + "smtp_use_ssl": { + "label": "SMTP SSL", + "help": "通过隐式 TLS 连接 SMTP,例如 465 端口(默认关闭)。实际使用中与 STARTTLS 互斥。" + }, + "verify_tls": { + "label": "校验 TLS 证书", + "help": "验证邮件服务器的 TLS 证书与主机名(推荐)。仅在使用自签名或内部 CA 服务器时关闭。" + }, + "from_address": { + "label": "发件地址", + "help": "回复邮件的发件地址。留空则沿用 SMTP 用户名。" + }, + "auto_reply_enabled": { + "label": "自动回复", + "help": "对入站邮件自动回复。智能体主动发起的发送不受影响。" + }, + "poll_interval_seconds": { + "label": "轮询间隔(秒)", + "help": "检查 IMAP 新邮件的频率(默认 30,最小 5)。" + }, + "mark_seen": { + "label": "标记为已读", + "help": "将已处理的邮件在邮箱中标记为已读。" + }, + "post_action": { + "label": "处理后动作", + "help": "已处理邮件的处理方式:delete(删除)、move(移动到其他文件夹),留空表示保留。" + }, + "post_action_move_mailbox": { + "label": "移动到文件夹", + "help": "“处理后动作”选择 move 时使用的目标文件夹。" + }, + "post_action_expunge": { + "label": "删除后彻底清除", + "help": "删除邮件后执行 expunge 彻底清除,而不是仅保留删除标记。" + }, + "post_action_ignore_skipped": { + "label": "跳过自身与已过滤邮件", + "help": "被跳过的邮件(自身邮件及被名单过滤的发件人)不受处理后动作影响(默认开启)。" + }, + "max_body_chars": { + "label": "正文最大字符数", + "help": "每封邮件正文截断到该字符数(默认 12000)。" + }, + "subject_prefix": { + "label": "回复主题前缀", + "help": "在回复主题前添加的前缀(默认 \"Re: \")。" + }, + "allow_from": { + "label": "允许的发件人", + "help": "可选的发件人邮箱白名单,只有名单内的地址可以与智能体对话(\"*\" 表示允许所有人)。未配对的发件人会收到一条包含配对码的回复。" + }, + "verify_dkim": { + "label": "校验 DKIM", + "help": "要求 Authentication-Results 头中 dkim=pass。同时关闭 DKIM 与 SPF 校验将接受伪造的 From 头。" + }, + "verify_spf": { + "label": "校验 SPF", + "help": "要求 Authentication-Results 头中 spf=pass。同时关闭 DKIM 与 SPF 校验将接受伪造的 From 头。" + }, + "allowed_attachment_types": { + "label": "允许的附件类型", + "help": "入站附件的 MIME 白名单,例如 application/pdf 或 image/*。留空表示禁用附件。" + }, + "max_attachment_size": { + "label": "附件大小上限(字节)", + "help": "单个附件允许的最大字节数(默认 2000000,即 2 MB)。" + }, + "max_attachments_per_email": { + "label": "单封邮件附件数上限", + "help": "一封邮件最多接受的附件数量(默认 5)。" + } + }, + "websocket": { + "host": { + "label": "绑定地址", + "help": "WebSocket 服务监听的本地地址(默认 127.0.0.1,仅本机可访问)。使用 0.0.0.0 或 :: 等通配地址会把服务暴露到所有网卡,除非设置了 token 或 token 下发密钥,否则会被拒绝。" + }, + "port": { + "label": "绑定端口", + "help": "WebSocket 服务监听的本地端口(默认 8765)。设置 Unix socket 路径时此项被忽略。" + }, + "unix_socket_path": { + "label": "Unix socket 路径", + "help": "可选的绝对路径。设置后服务监听该 Unix socket,而不再使用上方的主机和端口。" + }, + "path": { + "label": "升级路径", + "help": "客户端连接使用的 WebSocket 升级路径(默认 /),必须以“/”开头。" + }, + "token": { + "label": "静态令牌", + "help": "握手时通过 token 查询参数校验的共享密钥。仅保存在服务端,不会回传给浏览器。" + }, + "token_issue_path": { + "label": "令牌下发路径", + "help": "以 JSON 形式下发短期一次性令牌的 HTTP GET 路由。必须与升级路径不同。" + }, + "token_issue_secret": { + "label": "令牌下发密钥", + "help": "令牌下发路由要求的 Bearer 密钥(Authorization: Bearer 或 X-Vibe-Trading-Auth)。仅保存在服务端,不会回传给浏览器。" + }, + "token_ttl_s": { + "label": "令牌有效期(秒)", + "help": "令牌下发路由签发令牌的有效期,单位为秒(30–86400,默认 300)。" + }, + "websocket_requires_token": { + "label": "强制令牌", + "help": "拒绝未携带有效静态令牌或签发令牌的握手(默认开启)。" + }, + "allow_from": { + "label": "允许的客户端", + "help": "可选的客户端 ID 白名单,只有名单内的客户端可以连接(\"*\" 表示允许所有人,默认)。" + }, + "streaming": { + "label": "流式输出", + "help": "将模型输出增量实时推送给已连接的客户端。" + }, + "max_message_bytes": { + "label": "单条消息上限(字节)", + "help": "允许的最大入站帧字节数(默认 37748736;范围 1024–41943040)。" + }, + "ping_interval_s": { + "label": "Ping 间隔(秒)", + "help": "心跳 ping 的发送间隔秒数(5–300,默认 20)。" + }, + "ping_timeout_s": { + "label": "Ping 超时(秒)", + "help": "等待 ping 响应的秒数,超时后断开连接(5–300,默认 20)。" + }, + "ssl_certfile": { + "label": "TLS 证书路径", + "help": "WSS 使用的服务端 PEM 证书路径。证书与私钥需成对设置或同时留空;这里是服务器上的文件路径,不是上传的材料。" + }, + "ssl_keyfile": { + "label": "TLS 私钥路径", + "help": "WSS 使用的服务端 PEM 私钥路径。证书与私钥需成对设置或同时留空;这里是服务器上的文件路径,不是上传的材料。" + } } }, "guides": { @@ -542,6 +730,26 @@ "step4": "按平台的沙箱/发布规则,将机器人加入 QQ 群或与其私聊。", "step5": "回到这里点击“测试连接”,然后启用渠道。", "docsLabel": "打开 QQ 开放平台" + }, + "email": { + "title": "邮箱配置指南", + "intro": "邮箱渠道通过 IMAP 轮询收件、通过 SMTP 发送回复,因此无需公网回调地址。", + "step1": "从邮箱服务商处获取 IMAP 与 SMTP 的服务器地址和端口(通常是 imap.<服务商> 的 993 端口和 smtp.<服务商> 的 587 端口)。", + "step2": "在邮箱设置中开启 IMAP 与 SMTP 服务;如果服务商要求(Gmail、QQ 邮箱等启用两步验证的邮箱),请创建应用专用密码。", + "step3": "填写上方的 IMAP 与 SMTP 凭据,设置发件地址,并把你自己的邮箱加入“允许的发件人”。", + "step4": "点击“测试连接”,它会依次探测 IMAP 登录、邮箱文件夹和 SMTP 登录。", + "step5": "勾选“已授予同意”并启用渠道,自动回复即可送达通过 DKIM/SPF 校验的发件人。", + "docsLabel": "打开 Vibe-Trading 文档" + }, + "websocket": { + "title": "WebSocket 配置指南", + "intro": "WebSocket 渠道自带本地服务,客户端直接通过 WebSocket 地址连接,因此无需公网回调地址。", + "step1": "选择绑定地址:仅本机使用可保持 127.0.0.1;如需对外暴露可设置 0.0.0.0 或 ::,此时必须配置令牌或令牌下发密钥。", + "step2": "设置静态令牌,或配置令牌下发路径及其 Bearer 密钥,让客户端获取短期令牌。", + "step3": "客户端使用 ws://host:port/path?client_id=...&token=... 连接。", + "step4": "渠道运行中保存会热切换服务:已连接的客户端(包括本 Web UI 聊天)会短暂断开并自动重连。", + "step5": "点击“测试连接”,它会在本地校验 SSL 证书/私钥与地址可用性。", + "docsLabel": "打开 Vibe-Trading 文档" } } },