// SPLIT-EVAL: 已评估暂缓拆分——i18n 词条字典与配置表单逻辑共享同一份 data-i18n key 命名,且 src/web.rs 只内嵌 config.js 与 styles.css,拆成多文件需先引入构建步骤。 (() => { "use strict"; // ───────────────────────────────────────────────────────────────────────── // I18N — browser-language detection, zh/en message dictionaries, t() helper. // Stays vanilla JS; no build step, no extra asset file (src/web.rs only serves // config.js + styles.css). HTML holds English default text + data-i18n attrs; // applyI18n() swaps in Chinese when the browser prefers zh. Default: English. // ───────────────────────────────────────────────────────────────────────── const MESSAGES = Object.freeze({ en: { "app.title": "Service Configuration · MessageWeave", "brand.aria": "MessageWeave configuration home", "brand.tagline": "Service configuration", "topbar.note": "Secure configuration channel", "intro.title": "Manage your service connections", "intro.copy": "Configure JMAP, Telegram, LLM, access allowlist and runtime parameters. Business configuration is stored in external Redis and hot-reloaded immediately after a successful save.", "notice.bootstrap": "Create a short-lived admin session using the CONFIG_ENCRYPTION_KEY value.", "setup.eyebrow": "Setup guide", "setup.title": "Service startup configuration", "setup.checking": "Checking service startup configuration…", "setup.checkingBtn": "Checking…", "setup.privacy": "Startup secrets must be configured in the deployment environment. Secrets are never saved or echoed in the page.", "setup.retry": "Re-check", "setup.modeMissing": "The service is in setup mode. Provide the following startup variables in the deployment environment:", "setup.modeNoMissing": "The service is in setup mode, but the status endpoint did not return the missing startup variable names.", "setup.unknown": "Unable to confirm whether the service has completed startup configuration. The authorization entry stays hidden; re-check the service status.", "setup.fetchError": "Unable to read the service startup status right now. To protect management credentials, the authorization entry stays hidden; confirm the service is available and re-check.", "auth.eyebrow": "Authorization required", "auth.title": "Create admin session", "auth.bodyPre": "Enter ", "auth.bodyPost": ". The session lasts 30 minutes and lives only in this page's memory.", "auth.label": "Business encryption key", "auth.placeholder": "Paste the value", "auth.submit": "Create session", "auth.submitBusy": "Authorizing…", "auth.hint": "You must re-authorize after closing or refreshing the page.", "config.aria": "Business configuration and runtime parameters", "session.active": "Admin session active", "session.expiryDefault": "Session kept only in current page memory", "session.expiry": "Admin session expires in about {n} minutes; kept only in page memory", "session.expired": "The admin session has expired. The session and form contents have been cleared from the page. Please re-authorize.", "session.revoked": "Signed out; the admin session and secrets in the form have been cleared from page memory.", "session.invalid": "The admin session is no longer valid. The page contents have been cleared. Please recreate the session.", "session.invalidBusiness": "The admin session is no longer valid. The session and configuration have been cleared from the page. Please re-authorize.", "service.enable": "Enable business processing", "service.loading": "Loading…", "service.enabled": "Business entry enabled", "service.disabled": "Business entry disabled (fail-closed)", "service.enabledToast": "Business processing enabled.", "service.disabledToast": "Business processing disabled; the entry will return a not-enabled state.", "service.saveFail": "Unable to save the business toggle state.", "reload.button": "Reload runtime parameters", "reload.busy": "Loading…", "logout.button": "Sign out and clear session", "business.eyebrow": "Full replacement", "business.title": "Business connections and auth", "business.persist": "Encrypted in Redis", "callout.title": "Business configuration is not read from the server.", "callout.bodyPre": " To avoid secret echo, the API does not expose a business configuration GET. Every submission must re-fill the complete configuration, including all required secrets; optional LLM fields are sent as ", "callout.bodyPost": " when left blank. The mailbox primary account is always used.", "legend.business": "Full business configuration", "jmap.title": "JMAP mailbox", "jmap.desc": "The server must use HTTPS; the URL must not contain credentials, query parameters or fragments.", "jmap.urlHint": "HTTPS address without username, password, query parameters or #fragment.", "jmap.userLabel": "Email username", "jmap.passLabel": "App-specific password", "secret.reenter": "Re-enter on each full save", "telegram.title": "Telegram and access auth", "telegram.desc": "Only chat IDs in the allowlist may trigger bot operations.", "telegram.chatLabel": "Target Chat ID", "telegram.chatPh": "e.g. -1001234567890", "telegram.chatHint": "Accepts a full signed 64-bit integer.", "telegram.allowPh": "One chat ID per line or comma-separated", "telegram.allowHint": "Integer list; format and duplicates are checked before saving.", "telegram.workerPh": "Used by /worker and compatible admin APIs; re-enter on each full save", "telegram.workerHint": "This value differs from the current admin session. The page never reads or echoes an existing token.", "llm.title": "LLM service", "llm.desc": "When enabled, an HTTPS Base URL, model name and API key are required.", "llm.enableLabel": "Enable LLM", "llm.enableHint": "Allow the bot to call OpenAI-compatible model services.", "llm.netLabel": "Allow network calls", "llm.netHint": "When off, no LLM client is created.", "llm.urlHint": "HTTPS is required when LLM is enabled.", "llm.modelLabel": "Model name", "llm.modelPh": "Leave blank for null", "llm.keyPh": "Required when LLM is enabled; never echoed", "timezone.title": "Notification display", "timezone.desc": "Received times in Telegram notifications are rendered in this time zone.", "timezone.hint": "Only fixed-offset zones are supported; DST is not tracked.", "business.saveStateInitial": "You can fill in the full configuration after the admin session is verified.", "business.saveStateReady": "Only changed fields are submitted; untouched fields keep their saved values. Secrets are never echoed.", "business.save": "Save business config and hot-reload", "business.saveBusy": "Saving and hot-reloading…", "business.saved": "Business configuration saved and new configuration hot-reloaded.", "business.savedToast": "Business configuration saved and hot-reloaded. Secret inputs cleared; re-fill on the next full replacement.", "business.dirty": "The form has unsubmitted content; only the fields you changed will be submitted.", "business.conflict": "A newer configuration was stored meanwhile; the saved values were re-read.", "business.unsaved": "No save-success confirmation received; check the service status.", "business.submittingToast": "Submitting the full business configuration; the backend builds clients first, then saves and swaps the running instance…", "business.preflight": "Test connection", "business.preflightBusy": "Testing connection…", "business.preflightRunning": "Testing the JMAP and LLM endpoints; nothing is saved.", "business.preflightFailed": "The preflight request failed; the saved configuration is unchanged.", "business.preflightInvalid": "Not accepted by the backend validator:", "business.preflightOk": "All components connected successfully. The running configuration is unchanged.", "business.preflightPartial": "These components failed to build:", "business.preflightUnknown": "No component-level result was returned; the backend may predate the preflight endpoint.", "business.preflightPersisted": "Unexpected: the preflight response claims it persisted a configuration.", "business.savedWarning": "Saved, but not hot-reloaded: these components could not be built, so the running instance keeps the previous configuration until they do:", "runtime.eyebrow": "Outbound requests", "runtime.title": "Timeouts and retries", "runtime.persist": "Stored in Redis", "runtime.bodyPre": "Runtime parameters are read by ", "runtime.bodyMid": " and updated by ", "runtime.bodyPost": "; new values apply immediately to subsequent requests after saving.", "legend.runtime": "Runtime parameters", "runtime.jmapLabel": "JMAP timeout", "runtime.jmapDesc": "Upper wait limit for a single JMAP request.", "runtime.jmapHint": "100–300,000; default 15,000.", "runtime.tgLabel": "Telegram timeout", "runtime.tgDesc": "Upper wait limit for a single Telegram request.", "runtime.tgHint": "100–300,000; default 10,000.", "runtime.llmLabel": "LLM timeout", "runtime.llmDesc": "Upper wait limit for a single model request.", "runtime.llmHint": "100–300,000; default 30,000.", "runtime.retriesLabel": "Max retries", "runtime.retriesDesc": "Extra attempts after a failure; 0 means no retry.", "runtime.retriesHint": "0–5; default 3.", "runtime.saveStateInitial": "Runtime parameters become editable after a successful read.", "runtime.previewState": "Default-value preview; save is disabled until a read succeeds.", "runtime.loaded": "Runtime parameters read from Redis.", "runtime.readFailState": "Read failed; save is disabled.", "runtime.dirty": "There are unsaved runtime parameter changes.", "runtime.defaultsState": "Defaults restored as preview; they are written to Redis only after saving.", "runtime.saveUnconfirmed": "Save state unconfirmed; save is disabled until a successful re-read.", "runtime.defaults": "Restore defaults", "runtime.save": "Save runtime parameters", "runtime.saveBusy": "Saving…", "runtime.savingToast": "Saving runtime parameters to Redis…", "runtime.readingToast": "Reading runtime parameters from Redis…", "runtime.readToast": "Runtime parameters read. Business configuration still needs to be filled in manually.", "runtime.savedToast": "Runtime parameters saved to Redis and applied immediately to subsequent requests.", "runtime.defaultsToast": "Runtime parameters restored to default-value preview. They are written to Redis only after clicking \"Save runtime parameters\".", "privacy.title": "Secrets are never echoed or persisted to the browser.", "privacy.body": "Your settings are encrypted before they are saved on the server. Email contents are not shown here.", "common.required": "Required", "common.optional": "Optional", "common.buildVersion": "Build version", "common.buildVersionUnknown": "Build version unavailable", "common.atLeastOne": "At least one", "common.ms": "ms", "common.times": "times", "err.unrecognizedFormat": "The configuration service returned an unrecognized data format.", "err.connectFail": "Unable to connect to the configuration service. Confirm the service is available and retry.", "err.sessionInvalid": "The admin session is no longer valid. Please re-authorize.", "err.runtime400": "Invalid runtime parameter request format (HTTP 400).", "err.runtime422": "Runtime parameters out of allowed range (timeouts 100–300,000 ms, retries 0–5).", "err.runtime503save": "Redis unavailable or initialization incomplete; save state cannot be confirmed. Runtime parameter saving is disabled; restore the connection and re-read.", "err.runtime503load": "Redis unavailable or initialization incomplete. Only the default-value preview is shown and runtime parameter saving is disabled; retry shortly.", "err.runtimeFailStatus": "Runtime parameter request failed (HTTP {status}). Retry shortly.", "err.runtimeFail": "Runtime parameter request failed; check the service status and retry.", "err.unrecognizedRuntime": "The configuration service returned unrecognized runtime parameters.", "err.runtimeOutOfRange": "The configuration service returned out-of-range runtime parameters; check the backend configuration.", "err.invalidInt": "Please enter an integer.", "err.rangeInt": "Please enter an integer between {min} and {max}.", "val.i64": "{label} must be a decimal integer.", "val.i64Range": "{label} exceeds the signed 64-bit integer range.", "val.allowlistEmpty": "Enter at least one chat ID. Whitespace and commas both work as separators.", "val.allowlistNotInt": "\"{entry}\" in the allowlist is not an integer chat ID.", "val.allowlistRange": "\"{entry}\" in the allowlist exceeds the signed 64-bit integer range.", "val.allowlistDup": "Duplicate chat ID in the allowlist: {entry}.", "val.httpsUrl": "{label} must be a valid HTTPS URL.", "val.https": "{label} must use HTTPS.", "val.noCreds": "{label} must not contain a username or password.", "val.noQuery": "{label} must not contain query parameters or a #fragment.", "val.empty": "{label} cannot be empty.", "field.set": "Set", "field.unset": "Not set", "secret.marked": "• {state}. {hint}", "secret.keepEmpty": "Leave empty to keep the saved value", "secret.keepSet": "Empty; the saved value stays", "secret.mustEnter": "Enter a value to create the first configuration", "secret.changed": "New value will replace the saved one", "session.readbackToast": "Saved configuration loaded. Change only what you need.", "session.firstConfigToast": "No saved configuration yet. Fill in every field to create one.", "session.readbackFailed": "The saved configuration could not be loaded, so the form is empty. Nothing was changed.", "session.readback503": "The saved configuration could not be loaded from Redis. Try again later; your session is still valid.", "business.savedRevision": "Saved. Revision {revision}", "business.noChange": "Nothing to submit — no field differs from the saved configuration.", "val.llmUrl": "Base URL is required when LLM is enabled.", "val.llmModel": "Model name is required when LLM is enabled.", "val.llmKey": "API key is required when LLM is enabled.", "berr.401": "The admin session is no longer valid. The session and form contents have been cleared from the page; please re-authorize.", "berr.400": "The backend could not parse the submitted JSON (HTTP 400); check the fields and retry.", "berr.422": "Business configuration validation failed (HTTP 422). Check required fields, HTTPS URLs, LLM settings and the chat ID allowlist. The server does not return secrets or error details.", "berr.503": "The write could not be confirmed (HTTP 503): Redis could not persist the configuration or the hot reload could not be committed. A JMAP/LLM connection failure does not produce this error — those save anyway and are reported as warnings. The page keeps this input for review and retry.", "berr.409": "A newer configuration was stored after this page read it (HTTP 409). Your last submit was discarded, and the stored values have been re-read into this form, so the input you were typing may now show the saved value instead. Review the fields you changed and submit again.", "berr.status": "Business configuration request failed (HTTP {status}). Retry shortly.", "berr.fail": "{message} The request result may be unconfirmed; after checking, re-submit the complete configuration.", "berr.failDefault": "Business configuration request failed.", "login.enterCred": "Enter the CONFIG_ENCRYPTION_KEY value. The input is never written to browser storage.", "login.creatingToast": "Creating a short-lived admin session…", "login.invalidResponse": "The admin endpoint returned an invalid session response.", "login.successToast": "Admin session created. The saved configuration is being loaded; only change the fields you need.", "login.401": "The CONFIG_ENCRYPTION_KEY value does not match the server, or admin sessions are not enabled. The Redis ACL password is not accepted here.", "login.503": "Redis unavailable or initialization incomplete; unable to create an admin session right now. Retry shortly.", "login.status": "Unable to create an admin session (HTTP {status}).", "login.fail": "Unable to connect to the admin service; check the network and retry.", "callback.eyebrow": "External callbacks", "callback.title": "Register Telegram and Stalwart callbacks", "callback.copy": "Save the business configuration first. Enter the public HTTPS origin that receives callbacks, then register both services from this page.", "callback.originLabel": "Public HTTPS origin", "callback.originPlaceholder": "https://mail.example.com", "callback.originHint": "Use the Worker URL when enabled; otherwise use the backend URL. Enter only the origin, without a path.", "callback.state": "Registration is an explicit action and can be repeated safely.", "callback.saveFirst": "Save the current business configuration before registering callbacks.", "callback.register": "Register callbacks", "callback.registerBusy": "Registering…", "callback.done": "Telegram and Stalwart callbacks registered.", "callback.partial": "Telegram registration: {telegram}. Stalwart registration: {stalwart}.", "callback.invalidOrigin": "Enter an HTTPS origin without a path, credentials, query, or fragment.", "callback.telegramOk": "registered", "callback.pushOk": "registered", "callback.failed": "failed", }, zh: { "app.title": "业务配置 · MessageWeave", "brand.aria": "MessageWeave 配置首页", "brand.tagline": "业务配置", "topbar.note": "安全通道", "intro.title": "服务连接", "intro.copy": "设置 JMAP、Telegram、LLM、白名单和运行参数。", "notice.bootstrap": "使用部署密钥授权。", "setup.eyebrow": "配置引导", "setup.title": "服务启动配置", "setup.checking": "正在检查服务启动配置…", "setup.checkingBtn": "正在检查…", "setup.privacy": "启动所需密钥请在部署环境中配置。密钥不会在页面保存或回显。", "setup.retry": "重新检查", "setup.modeMissing": "服务处于配置引导模式。请在部署环境补齐以下启动变量:", "setup.modeNoMissing": "服务处于配置引导模式,但状态接口未返回缺少的启动变量名称。", "setup.unknown": "无法确认服务是否已完成启动配置。管理授权入口保持隐藏,请重新检查服务状态。", "setup.fetchError": "暂时无法读取服务启动状态。为保护管理凭据,授权入口保持隐藏;请确认服务可用后重新检查。", "auth.eyebrow": "需要授权", "auth.title": "管理员授权", "auth.bodyPre": "输入 ", "auth.bodyPost": ",会话有效 30 分钟,仅保存在当前页面。", "auth.label": "业务密钥", "auth.placeholder": "粘贴部署密钥", "auth.submit": "授权", "auth.submitBusy": "正在授权…", "auth.hint": "刷新或关闭页面后需重新授权。", "config.aria": "业务配置与运行参数", "session.active": "管理会话有效", "session.expiryDefault": "会话仅保存在当前页面内存", "session.expiry": "会话约 {n} 分钟后过期;只保存在页面内存", "session.expired": "管理会话已过期,已清除页面中的会话和表单内容。请重新授权。", "session.revoked": "已退出;管理会话和表单中的密钥已从页面内存清除。", "session.invalid": "管理会话已失效,页面内容已清除。请重新创建会话。", "session.invalidBusiness": "管理会话已失效,已清除页面中的会话与配置。请重新授权。", "service.enable": "启用业务处理", "service.loading": "读取中…", "service.enabled": "业务入口已启用", "service.disabled": "业务入口已关闭(fail-closed)", "service.enabledToast": "业务处理已启用。", "service.disabledToast": "业务处理已关闭,入口将返回未启用状态。", "service.saveFail": "无法保存业务开关状态。", "reload.button": "重新读取运行参数", "reload.busy": "正在读取…", "logout.button": "退出并清除会话", "business.eyebrow": "完整替换", "business.title": "业务连接与鉴权", "business.persist": "Redis 加密保存", "callout.title": "业务配置不会从服务器读取。", "callout.bodyPre": " 为避免密钥回显,API 不提供业务配置 GET。每次提交都必须重新填写完整配置,包括所有必填密钥;可选的 LLM 字段留空时会发送为 ", "callout.bodyPost": "。始终使用邮箱主账户。", "legend.business": "完整业务配置", "jmap.title": "JMAP 邮箱", "jmap.desc": "服务端必须使用 HTTPS,URL 中不能包含凭据、查询参数或片段。", "jmap.urlHint": "HTTPS 地址,不含用户名、密码、查询参数或 #片段。", "jmap.userLabel": "邮箱用户名", "jmap.passLabel": "应用专用密码", "secret.reenter": "每次完整保存时重新输入", "telegram.title": "Telegram 与访问鉴权", "telegram.desc": "仅允许白名单中的 chat ID 触发机器人操作。", "telegram.chatLabel": "目标 Chat ID", "telegram.chatPh": "例如 -1001234567890", "telegram.chatHint": "接受完整有符号 64 位整数。", "telegram.allowPh": "每行或逗号分隔一个 chat ID", "telegram.allowHint": "整数列表;保存前会检查格式与重复项。", "telegram.workerPh": "/worker 与兼容管理 API 使用;每次完整保存时重新输入", "telegram.workerHint": "此值与当前管理会话不同。页面不会读取或回显已有 token。", "llm.title": "LLM 服务", "llm.desc": "启用后要求 HTTPS Base URL、模型名和 API key。", "llm.enableLabel": "启用 LLM", "llm.enableHint": "允许机器人调用兼容 OpenAI 的模型服务。", "llm.netLabel": "允许网络调用", "llm.netHint": "关闭时不会创建 LLM 客户端。", "llm.urlHint": "启用 LLM 时必须使用 HTTPS。", "llm.modelLabel": "模型名称", "llm.modelPh": "留空表示 null", "llm.keyPh": "启用 LLM 时必填;不会回显", "business.saveStateInitial": "完成管理会话验证后可填写完整配置。", "business.saveStateReady": "只提交被修改的字段,未改动的字段保持已保存的值。密钥不会回显。", "business.save": "保存业务配置并热加载", "business.saveBusy": "正在保存并热加载…", "business.saved": "业务配置已保存,新配置已热加载。", "business.savedToast": "业务配置已保存并热加载。密钥输入已清空;下次完整替换时需要重新填写。", "business.dirty": "表单包含未提交内容;只提交你修改过的字段。", "business.conflict": "期间有人保存了更新的配置;已重新读取已保存的值。", "business.unsaved": "未收到保存成功确认;请检查服务状态。", "business.submittingToast": "正在提交完整业务配置;后端会先构建客户端,再保存并切换运行实例…", "business.preflight": "测试连接", "business.preflightBusy": "正在测试连接…", "business.preflightRunning": "正在测试 JMAP 与 LLM 端点;不会写入任何配置。", "business.preflightFailed": "预检请求失败;已保存的配置未受影响。", "business.preflightInvalid": "后端校验未通过:", "business.preflightOk": "各组件连接成功。当前运行配置未被修改。", "business.preflightPartial": "以下组件构建失败:", "business.preflightUnknown": "后端未返回组件级结果;可能运行的是不含预检接口的旧版本。", "business.preflightPersisted": "异常:预检响应声称已写入配置。", "business.savedWarning": "已保存,但未热加载:以下组件无法构建,运行实例将继续沿用旧配置,直到它们可以构建:", "runtime.eyebrow": "出站请求", "runtime.title": "超时与重试", "runtime.persist": "Redis 保存", "runtime.bodyPre": "运行参数由 ", "runtime.bodyMid": " 读取、由 ", "runtime.bodyPost": " 更新;新值保存后立即用于后续请求。", "legend.runtime": "运行参数", "runtime.jmapLabel": "JMAP 超时", "runtime.jmapDesc": "单次 JMAP 请求的等待上限。", "runtime.jmapHint": "100–300,000;默认 15,000。", "runtime.tgLabel": "Telegram 超时", "runtime.tgDesc": "单次 Telegram 请求的等待上限。", "runtime.tgHint": "100–300,000;默认 10,000。", "runtime.llmLabel": "LLM 超时", "runtime.llmDesc": "单次模型请求的等待上限。", "runtime.llmHint": "100–300,000;默认 30,000。", "runtime.retriesLabel": "最大重试次数", "runtime.retriesDesc": "失败后额外尝试的次数,0 表示不重试。", "runtime.retriesHint": "0–5;默认 3。", "runtime.saveStateInitial": "读取成功后可编辑运行参数。", "runtime.previewState": "默认值预览;读取后才能保存。", "runtime.loaded": "已读取 Redis 中的运行参数。", "runtime.readFailState": "读取未成功,保存已禁用。", "runtime.dirty": "有尚未保存的运行参数修改。", "runtime.defaultsState": "已恢复默认预览值;保存后才会写入 Redis。", "runtime.saveUnconfirmed": "保存状态无法确认;重新读取成功前已禁用保存。", "runtime.defaults": "恢复默认值", "runtime.save": "保存运行参数", "runtime.saveBusy": "正在保存…", "runtime.savingToast": "正在保存运行参数到 Redis…", "runtime.readingToast": "正在从 Redis 读取运行参数…", "runtime.readToast": "运行参数已读取。业务配置仍需手动完整填写。", "runtime.savedToast": "运行参数已保存到 Redis,并立即应用于后续请求。", "runtime.defaultsToast": "运行参数已恢复为默认值预览。点击“保存运行参数”后才会更新 Redis。", "privacy.title": "密钥不会显示或保存在浏览器里。", "privacy.body": "你的设置会加密后保存在服务器上。这里不会显示邮件内容。", "common.required": "必填", "common.optional": "可选", "common.buildVersion": "构建版本", "common.buildVersionUnknown": "构建版本未知", "common.atLeastOne": "至少一项", "common.ms": "毫秒", "common.times": "次", "err.unrecognizedFormat": "配置服务返回了无法识别的数据格式。", "err.connectFail": "无法连接配置服务。请确认服务可用后重试。", "err.sessionInvalid": "管理会话已失效,请重新授权。", "err.runtime400": "运行参数请求格式无效(HTTP 400)。", "err.runtime422": "运行参数超出允许范围(超时 100–300,000 毫秒,重试 0–5 次)。", "err.runtime503save": "Redis 不可用或初始化尚未完成;保存状态无法确认。已禁用运行参数保存,请恢复连接后重新读取。", "err.runtime503load": "Redis 不可用或初始化尚未完成。当前只显示默认值预览,运行参数保存已禁用;请稍后重试。", "err.runtimeFailStatus": "运行参数请求失败(HTTP {status})。请稍后重试。", "err.runtimeFail": "运行参数请求失败,请检查服务状态后重试。", "err.unrecognizedRuntime": "配置服务返回了无法识别的运行参数。", "err.runtimeOutOfRange": "配置服务返回了超出范围的运行参数,请检查后端配置。", "err.invalidInt": "请输入整数。", "err.rangeInt": "请输入 {min} 到 {max} 之间的整数。", "val.i64": "{label} 必须是十进制整数。", "val.i64Range": "{label} 超出有符号 64 位整数范围。", "val.allowlistEmpty": "至少填写一个 chat ID。空白和逗号均可用作分隔符。", "val.allowlistNotInt": "白名单中的“{entry}”不是整数 chat ID。", "val.allowlistRange": "白名单中的“{entry}”超出有符号 64 位整数范围。", "val.allowlistDup": "白名单中有重复的 chat ID:{entry}。", "val.httpsUrl": "{label} 必须是有效的 HTTPS URL。", "val.https": "{label} 必须使用 HTTPS。", "val.noCreds": "{label} 不能包含用户名或密码。", "val.noQuery": "{label} 不能包含查询参数或 #片段。", "val.empty": "{label} 不能为空。", "field.set": "已配置", "field.unset": "未配置", "secret.marked": "• {state}。{hint}", "secret.keepEmpty": "留空即保持已保存的值", "secret.keepSet": "留空;已保存的值不变", "secret.mustEnter": "请填写,以创建首次配置", "secret.changed": "将用新值替换已保存的值", "session.readbackToast": "已载入保存的配置。只需修改要改的字段。", "session.firstConfigToast": "尚无可保存的配置。请填写全部字段以创建首次配置。", "session.readbackFailed": "保存的配置未能载入,表单为空。未做任何更改。", "session.readback503": "Redis 中的保存配置暂不可读,请稍后重试;会话仍然有效。", "business.savedRevision": "已保存。版本 {revision}", "business.noChange": "没有内容需要提交——所有字段都与已保存的配置一致。", "val.llmUrl": "启用 LLM 时必须填写 Base URL。", "val.llmModel": "启用 LLM 时必须填写模型名称。", "val.llmKey": "启用 LLM 时必须填写 API key。", "berr.401": "管理会话已失效。页面已清除会话和表单内容,请重新授权。", "berr.400": "后端无法解析提交的 JSON(HTTP 400);请检查字段后重试。", "berr.422": "业务配置校验失败(HTTP 422)。请检查必填项、HTTPS URL、LLM 设置和 chat ID 白名单。服务端没有返回密钥或错误详情。", "berr.503": "写入未能确认(HTTP 503):Redis 未能持久化配置,或热加载提交失败。JMAP/LLM 连接失败不会返回此错误——它们会照常保存并以下发告警的形式报告。页面保留本次输入供检查和重试。", "berr.409": "本页读取配置之后,又有人保存了更新的版本(HTTP 409)。你刚才的提交已被丢弃,已把已保存的值重新读回表单,所以原本正在输入的内容现在可能显示为已保存的值。请核对修改过的字段后重新提交。", "berr.status": "业务配置请求失败(HTTP {status})。请稍后重试。", "berr.fail": "{message} 请求结果可能无法确认;检查后可重新提交完整配置。", "berr.failDefault": "业务配置请求失败。", "login.enterCred": "请输入 CONFIG_ENCRYPTION_KEY 的值。输入不会写入浏览器存储。", "login.creatingToast": "正在创建短期管理会话…", "login.invalidResponse": "管理接口返回了无效的会话响应。", "login.successToast": "管理会话已创建。正在载入已保存的配置,只需修改要改的字段。", "login.401": "CONFIG_ENCRYPTION_KEY 与服务器配置不一致,或服务尚未启用管理会话。此处不接受 Redis ACL 密码。", "login.503": "Redis 不可用或初始化尚未完成,暂时无法创建管理会话。请稍后重试。", "login.status": "无法创建管理会话(HTTP {status})。", "login.fail": "无法连接管理服务,请检查网络后重试。", "timezone.title": "通知显示", "timezone.desc": "Telegram 通知中的收件时间按此时区渲染。", "callback.eyebrow": "外部回调", "callback.title": "注册 Telegram 和 Stalwart 回调", "callback.copy": "先保存业务配置,再输入接收回调的公开 HTTPS 地址。本页会向两个服务注册回调。", "callback.originLabel": "公开 HTTPS 地址", "callback.originPlaceholder": "https://mail.example.com", "callback.originHint": "启用 Worker 时填写 Worker 地址,否则填写后端地址。只填写站点 origin,不带路径。", "callback.state": "注册需要显式操作,可以安全地重复执行。", "callback.saveFirst": "请先保存当前业务配置,再注册回调。", "callback.register": "注册回调", "callback.registerBusy": "正在注册…", "callback.done": "Telegram 和 Stalwart 回调均已注册。", "callback.partial": "Telegram 注册:{telegram}。Stalwart 注册:{stalwart}。", "callback.invalidOrigin": "请输入不带路径、凭据、查询参数或片段的 HTTPS origin。", "callback.telegramOk": "已注册", "callback.pushOk": "已注册", "callback.failed": "失败", "timezone.hint": "仅支持固定偏移时区;不跟踪夏令时。", }, }); // 后端 timezone 字段的白名单(全部固定偏移,按偏移升序;Asia/Shanghai 为默认值) const DEFAULT_TIMEZONE = Object.freeze("Asia/Shanghai"); const TIMEZONES = Object.freeze([ Object.freeze({ iana: "Etc/UTC", offset: "+0", label: Object.freeze({ en: "UTC (UTC+0)", zh: "协调世界时 (UTC+0)" }) }), Object.freeze({ iana: "Africa/Cairo", offset: "+2", label: Object.freeze({ en: "Cairo, Egypt (UTC+2)", zh: "埃及开罗 (UTC+2)" }) }), Object.freeze({ iana: "Europe/Istanbul", offset: "+3", label: Object.freeze({ en: "Istanbul, Turkey (UTC+3)", zh: "土耳其伊斯坦布尔 (UTC+3)" }) }), Object.freeze({ iana: "Africa/Nairobi", offset: "+3", label: Object.freeze({ en: "Nairobi, Kenya (UTC+3)", zh: "肯尼亚内罗毕 (UTC+3)" }) }), Object.freeze({ iana: "Asia/Dubai", offset: "+4", label: Object.freeze({ en: "Dubai, UAE (UTC+4)", zh: "阿联酋迪拜 (UTC+4)" }) }), Object.freeze({ iana: "Asia/Karachi", offset: "+5", label: Object.freeze({ en: "Karachi, Pakistan (UTC+5)", zh: "巴基斯坦卡拉奇 (UTC+5)" }) }), Object.freeze({ iana: "Asia/Kolkata", offset: "+5:30", label: Object.freeze({ en: "Kolkata, India (UTC+5:30)", zh: "印度加尔各答 (UTC+5:30)" }) }), Object.freeze({ iana: "Asia/Bangkok", offset: "+7", label: Object.freeze({ en: "Bangkok, Thailand (UTC+7)", zh: "泰国曼谷 (UTC+7)" }) }), Object.freeze({ iana: "Asia/Ho_Chi_Minh", offset: "+7", label: Object.freeze({ en: "Ho Chi Minh City, Vietnam (UTC+7)", zh: "越南胡志明市 (UTC+7)" }) }), Object.freeze({ iana: "Asia/Shanghai", offset: "+8", label: Object.freeze({ en: "Shanghai, China (UTC+8)", zh: "中国上海 (UTC+8)" }) }), Object.freeze({ iana: "Asia/Hong_Kong", offset: "+8", label: Object.freeze({ en: "Hong Kong (UTC+8)", zh: "中国香港 (UTC+8)" }) }), Object.freeze({ iana: "Asia/Taipei", offset: "+8", label: Object.freeze({ en: "Taipei, Taiwan (UTC+8)", zh: "中国台北 (UTC+8)" }) }), Object.freeze({ iana: "Asia/Singapore", offset: "+8", label: Object.freeze({ en: "Singapore (UTC+8)", zh: "新加坡 (UTC+8)" }) }), Object.freeze({ iana: "Asia/Manila", offset: "+8", label: Object.freeze({ en: "Manila, Philippines (UTC+8)", zh: "菲律宾马尼拉 (UTC+8)" }) }), Object.freeze({ iana: "Asia/Tokyo", offset: "+9", label: Object.freeze({ en: "Tokyo, Japan (UTC+9)", zh: "日本东京 (UTC+9)" }) }), Object.freeze({ iana: "Asia/Seoul", offset: "+9", label: Object.freeze({ en: "Seoul, South Korea (UTC+9)", zh: "韩国首尔 (UTC+9)" }) }), ]); function populateTimezones() { const select = document.querySelector("#timezone"); for (const zone of TIMEZONES) { const option = document.createElement("option"); option.value = zone.iana; option.textContent = zone.label[locale] || zone.label.en; option.selected = zone.iana === DEFAULT_TIMEZONE; select.appendChild(option); } } function detectLocale() { let pref = ""; try { if (typeof navigator !== "undefined" && navigator) { pref = (Array.isArray(navigator.languages) && navigator.languages[0]) || navigator.language || ""; } } catch { pref = ""; } if (typeof pref === "string" && pref.toLowerCase().startsWith("zh")) return "zh"; return "en"; } let locale = detectLocale(); function t(key, vars) { const dict = MESSAGES[locale] || MESSAGES.en; let str = dict[key]; if (str === undefined) str = MESSAGES.en[key]; if (str === undefined) return key; if (vars) { for (const [name, value] of Object.entries(vars)) { str = str.split(`{${name}}`).join(String(value)); } } return str; } function joinList(items) { const sep = locale === "zh" ? "、" : ", "; return items.join(sep); } function applyI18n() { const doc = (typeof document !== "undefined") ? document : null; if (!doc) return; const root = doc.documentElement; if (root && root.setAttribute) root.setAttribute("lang", locale === "zh" ? "zh-CN" : "en"); if (typeof doc.querySelectorAll !== "function") return; for (const el of doc.querySelectorAll("[data-i18n]")) { const key = el.getAttribute("data-i18n"); if (key) el.textContent = t(key); } for (const el of doc.querySelectorAll("[data-i18n-placeholder]")) { const key = el.getAttribute("data-i18n-placeholder"); if (key) el.setAttribute("placeholder", t(key)); } for (const el of doc.querySelectorAll("[data-i18n-aria]")) { const key = el.getAttribute("data-i18n-aria"); if (key) el.setAttribute("aria-label", t(key)); } } applyI18n(); populateTimezones(); // ───────────────────────────────────────────────────────────────────────── const RUNTIME_DEFAULTS = Object.freeze({ jmap_timeout_ms: 15000, telegram_timeout_ms: 10000, llm_timeout_ms: 30000, max_retries: 3, }); const RUNTIME_LIMITS = Object.freeze({ jmap_timeout_ms: { min: 100, max: 300000 }, telegram_timeout_ms: { min: 100, max: 300000 }, llm_timeout_ms: { min: 100, max: 300000 }, max_retries: { min: 0, max: 5 }, }); const RUNTIME_FIELDS = Object.freeze([ ["jmap_timeout_ms", "jmap-timeout"], ["telegram_timeout_ms", "telegram-timeout"], ["llm_timeout_ms", "llm-timeout"], ["max_retries", "max-retries"], ]); const I64_MIN = -(2n ** 63n); const I64_MAX = (2n ** 63n) - 1n; /* * Business config read-back + partial submission. * * Secrets are never echoed by the backend: the server returns only a presence * boolean per secret field (SAF-NO-SECRET-ECHO). A blank secret input therefore * means "keep the saved value" and the field is omitted from the request body; * a non-blank value replaces it. There is no way to clear a secret through the UI. * * The first save (no saved configuration yet) is still a full submission. */ const SECRET_FIELDS = Object.freeze([ Object.freeze({ key: "bot_token", id: "telegram-bot-token" }), Object.freeze({ key: "jmap_password", id: "jmap-password" }), Object.freeze({ key: "telegram_webhook_secret", id: "telegram-webhook-secret" }), Object.freeze({ key: "reconcile_token", id: "reconcile-token" }), Object.freeze({ key: "worker_token", id: "worker-token" }), Object.freeze({ key: "llm_api_key", id: "llm-api-key" }), ]); const PLAIN_TEXT_FIELDS = Object.freeze([ Object.freeze({ key: "jmap_session_url", id: "jmap-session-url", https: "JMAP Session URL" }), Object.freeze({ key: "jmap_username", id: "jmap-username" }), Object.freeze({ key: "timezone", id: "timezone" }), Object.freeze({ key: "llm_base_url", id: "llm-base-url", optional: true, https: "LLM Base URL" }), Object.freeze({ key: "llm_model", id: "llm-model", optional: true }), ]); const PLAIN_BOOL_FIELDS = Object.freeze([ Object.freeze({ key: "llm_enabled", id: "llm-enabled" }), Object.freeze({ key: "llm_allow_net", id: "llm-allow-net" }), ]); const bootstrapInput = document.querySelector("#bootstrap-input"); const authForm = document.querySelector("#auth-form"); const intro = document.querySelector("#intro"); const authCard = document.querySelector("#auth-card"); const setupCard = document.querySelector("#setup-card"); const setupMessage = document.querySelector("#setup-message"); const setupMissing = document.querySelector("#setup-missing"); const statusRetryButton = document.querySelector("#status-retry-button"); const privacyNote = document.querySelector("#privacy-note"); const configPanel = document.querySelector("#config-panel"); const businessForm = document.querySelector("#business-form"); const businessFields = document.querySelector("#business-fields"); const runtimeForm = document.querySelector("#runtime-form"); const runtimeFields = document.querySelector("#runtime-fields"); const connectButton = document.querySelector("#connect-button"); const reloadButton = document.querySelector("#reload-button"); const logoutButton = document.querySelector("#logout-button"); const defaultsButton = document.querySelector("#defaults-button"); const businessSaveButton = document.querySelector("#business-save-button"); const callbackOrigin = document.querySelector("#callback-origin"); const callbackRegisterButton = document.querySelector("#callback-register-button"); const callbackState = document.querySelector("#callback-state"); const runtimeSaveButton = document.querySelector("#runtime-save-button"); const businessSaveState = document.querySelector("#business-save-state"); const runtimeSaveState = document.querySelector("#runtime-save-state"); const serviceEnabled = document.querySelector("#service-enabled"); const serviceEnabledState = document.querySelector("#service-enabled-state"); const sessionExpiry = document.querySelector("#session-expiry"); const llmEnabled = document.querySelector("#llm-enabled"); const llmApiKey = document.querySelector("#llm-api-key"); const llmBaseUrl = document.querySelector("#llm-base-url"); const llmModel = document.querySelector("#llm-model"); const notice = document.querySelector("#notice"); const preflightButton = document.querySelector("#business-preflight-button"); const versionFooter = document.querySelector("#build-version"); const versionTop = document.querySelector("#build-version-top"); const versionSetup = document.querySelector("#build-version-setup"); // C-NO-LOCAL-WRITE / C-REDIS-ONLY-STATE: credentials and configuration stay in page memory. let adminSession = ""; let expiryTimer = 0; let busyAction = ""; let runtimeLoaded = false; let setupMode = true; // Business-config readback from GET /api/business-config. Both null means no // saved configuration exists yet, so submission falls back to the original // full-payload path. `businessBaseline` is the non-secret `values` object // (including nulls); `businessSecretPresence` is the `secrets_present` object. // Secrets themselves are never stored here — only their presence booleans. let businessBaseline = null; // Revision the baseline was read at. Echoed back on a partial submit so the // backend can reject this page as stale instead of overwriting a newer save. let businessRevision = null; let businessSecretPresence = null; class ApiError extends Error { constructor(status, message) { super(message); this.status = status; } } function showNotice(kind, message) { notice.hidden = false; notice.className = `notice notice-${kind}`; notice.textContent = message; notice.setAttribute("role", kind === "error" ? "alert" : "status"); } function updateButtons() { const busy = Boolean(busyAction); connectButton.disabled = busy; reloadButton.disabled = busy || !adminSession; logoutButton.disabled = busy || !adminSession; businessSaveButton.disabled = busy || !adminSession; callbackRegisterButton.disabled = busy || !adminSession || businessBaseline === null || businessSaveState.classList.contains("is-dirty"); preflightButton.disabled = busy || !adminSession; runtimeSaveButton.disabled = busy || !runtimeLoaded || !adminSession; defaultsButton.disabled = busy || !runtimeLoaded || !adminSession; businessSaveButton.classList.toggle("is-busy", busy && busyAction === "business-save"); runtimeSaveButton.classList.toggle("is-busy", busy && busyAction === "runtime-save"); preflightButton.classList.toggle("is-busy", busy && busyAction === "business-preflight"); connectButton.textContent = busy && busyAction === "login" ? t("auth.submitBusy") : t("auth.submit"); reloadButton.textContent = busy && busyAction === "runtime-load" ? t("reload.busy") : t("reload.button"); businessSaveButton.querySelector(".button-label").textContent = busy && busyAction === "business-save" ? t("business.saveBusy") : t("business.save"); runtimeSaveButton.querySelector(".button-label").textContent = busy && busyAction === "runtime-save" ? t("runtime.saveBusy") : t("runtime.save"); preflightButton.textContent = busy && busyAction === "business-preflight" ? t("business.preflightBusy") : t("business.preflight"); callbackRegisterButton.textContent = busy && busyAction === "callback-register" ? t("callback.registerBusy") : t("callback.register"); } function setBusy(action = "") { busyAction = action; updateButtons(); } function parseResponseText(text, status) { if (status === 204 || text.trim() === "") return null; try { return JSON.parse(text); } catch { throw new Error(t("err.unrecognizedFormat")); } } async function request(path, method = "GET", body = undefined, bearer = adminSession) { const headers = new Headers({ Accept: "application/json" }); if (bearer) headers.set("Authorization", `Bearer ${bearer}`); const options = { method, headers, cache: "no-store", credentials: "omit", redirect: "error", referrerPolicy: "no-referrer", }; if (body !== undefined) { headers.set("Content-Type", "application/json"); options.body = typeof body === "string" ? body : JSON.stringify(body); } let response; try { response = await fetch(path, options); } catch { throw new Error(t("err.connectFail")); } const responseText = await response.text().catch(() => ""); if (!response.ok) throw new ApiError(response.status, responseText); return parseResponseText(responseText, response.status); } // The build fingerprint comes from /api/status so the SPA never hard-codes one: the binary // supplies it at compile time and the page just echoes it. Absent means the backend is older // than the version endpoint, which is itself useful information for an operator. function renderVersion(version) { const value = typeof version === "string" && version.trim() ? version.trim() : ""; const label = t("common.buildVersion"); if (versionFooter) versionFooter.textContent = value ? `${label} ${value}` : t("common.buildVersionUnknown"); if (versionTop) versionTop.textContent = value; if (versionSetup) versionSetup.textContent = value ? `${label} ${value}` : ""; } async function showSetupStatus(options = {}) { showSetupView(t("setup.checking")); statusRetryButton.disabled = true; statusRetryButton.textContent = t("setup.checkingBtn"); try { const status = await request("/api/status", "GET", undefined, ""); renderVersion(status && status.version); if (status && status.ready === true) { setupMode = false; setupCard.hidden = true; notice.hidden = false; intro.hidden = false; authCard.hidden = false; configPanel.hidden = true; privacyNote.hidden = false; showNotice("info", t("notice.bootstrap")); return; } const isSetup = status && status.ready === false && status.mode === "configuration-setup"; if (isSetup) { const knownMissing = Array.isArray(status.missing) ? status.missing.filter((name) => name === "REDIS_URL" || name === "CONFIG_ENCRYPTION_KEY") : []; const message = knownMissing.length ? t("setup.modeMissing") : t("setup.modeNoMissing"); showSetupView(message, knownMissing); return; } showSetupView(t("setup.unknown")); } catch { showSetupView(t("setup.fetchError")); } finally { statusRetryButton.disabled = false; statusRetryButton.textContent = t("setup.retry"); } } async function loadEnabled() { const state = await request("/api/enabled"); serviceEnabled.checked = state.enabled === true; serviceEnabledState.textContent = serviceEnabled.checked ? t("service.enabled") : t("service.disabled"); } function showSetupView(message, missing = []) { setupMode = true; setupMessage.textContent = message; setupMissing.textContent = joinList(missing); setupMissing.hidden = missing.length === 0; setupCard.hidden = false; intro.hidden = true; notice.hidden = true; authCard.hidden = true; configPanel.hidden = true; privacyNote.hidden = true; } function showConfigView() { setupCard.hidden = true; intro.hidden = false; privacyNote.hidden = false; authCard.hidden = true; configPanel.hidden = false; } function showAuthView() { setupCard.hidden = true; intro.hidden = false; privacyNote.hidden = false; configPanel.hidden = true; authCard.hidden = setupMode; bootstrapInput.value = ""; if (!setupMode) bootstrapInput.focus(); } function clearSecretFields() { for (const input of document.querySelectorAll('input[type="password"]')) input.value = ""; } function clearForms() { businessForm.reset(); runtimeForm.reset(); runtimeLoaded = false; runtimeFields.disabled = true; businessFields.disabled = true; runtimeSaveState.textContent = t("runtime.saveStateInitial"); businessSaveState.textContent = t("business.saveStateInitial"); resetBusinessReadback(); updateLLMRequirements(); } function clearSession() { adminSession = ""; if (expiryTimer) window.clearTimeout(expiryTimer); expiryTimer = 0; sessionExpiry.textContent = t("session.expiryDefault"); clearForms(); clearSecretFields(); setBusy(""); showAuthView(); } function expireSession(message) { clearSession(); showNotice("error", message); } function scheduleSessionExpiry(seconds) { if (expiryTimer) window.clearTimeout(expiryTimer); const duration = Math.max(1, Math.min(Number(seconds) || 1_800, 1_800)); sessionExpiry.textContent = t("session.expiry", { n: Math.ceil(duration / 60) }); expiryTimer = window.setTimeout(() => { expireSession(t("session.expired")); }, duration * 1000); } function normalizeRuntimeConfig(value) { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new Error(t("err.unrecognizedRuntime")); } const config = {}; for (const [key] of RUNTIME_FIELDS) { const item = value[key]; const limit = RUNTIME_LIMITS[key]; if (!Number.isInteger(item) || item < limit.min || item > limit.max) { throw new Error(t("err.runtimeOutOfRange")); } config[key] = item; } return config; } function fillRuntimeFields(config) { for (const [key, id] of RUNTIME_FIELDS) document.getElementById(id).value = String(config[key]); } function showRuntimePreview() { fillRuntimeFields(RUNTIME_DEFAULTS); runtimeLoaded = false; runtimeFields.disabled = true; runtimeSaveState.textContent = t("runtime.previewState"); updateButtons(); } function activateRuntimeConfig(config) { fillRuntimeFields(config); runtimeLoaded = true; runtimeFields.disabled = false; runtimeSaveState.textContent = t("runtime.loaded"); runtimeSaveState.classList.remove("is-dirty"); updateButtons(); } function runtimeErrorMessage(error, phase) { if (error.status === 401) return t("err.sessionInvalid"); if (error.status === 400) return t("err.runtime400"); if (error.status === 422) return t("err.runtime422"); if (error.status === 503) return phase === "save" ? t("err.runtime503save") : t("err.runtime503load"); if (error.status !== undefined) return t("err.runtimeFailStatus", { status: error.status }); return error.message || t("err.runtimeFail"); } async function loadRuntimeConfig() { if (!adminSession || busyAction) return; showRuntimePreview(); setBusy("runtime-load"); showNotice("info", t("runtime.readingToast")); try { const config = normalizeRuntimeConfig(await request("/api/config")); activateRuntimeConfig(config); showNotice("success", t("runtime.readToast")); } catch (error) { if (error.status === 401) { expireSession(t("session.invalid")); return; } runtimeLoaded = false; runtimeFields.disabled = true; runtimeSaveState.textContent = t("runtime.readFailState"); updateButtons(); showNotice("error", runtimeErrorMessage(error, "load")); } finally { if (adminSession) setBusy(""); } } function setFieldError(input, message) { input.setCustomValidity(message); input.reportValidity(); input.focus(); return null; } function clearCustomValidity(form) { for (const input of form.querySelectorAll("input, textarea")) input.setCustomValidity(""); } function isBlank(value) { return value.trim() === ""; } function parseI64(input, label) { const value = input.value.trim(); if (!/^-?\d+$/.test(value)) { setFieldError(input, t("val.i64", { label })); throw new Error("invalid-field"); } const parsed = BigInt(value); if (parsed < I64_MIN || parsed > I64_MAX) { setFieldError(input, t("val.i64Range", { label })); throw new Error("invalid-field"); } return parsed; } function parseAllowlist(input) { const entries = input.value.split(/[\s,]+/).filter(Boolean); if (entries.length === 0) { setFieldError(input, t("val.allowlistEmpty")); throw new Error("invalid-field"); } const ids = []; const seen = new Set(); for (const entry of entries) { if (!/^-?\d+$/.test(entry)) { setFieldError(input, t("val.allowlistNotInt", { entry })); throw new Error("invalid-field"); } const id = BigInt(entry); if (id < I64_MIN || id > I64_MAX) { setFieldError(input, t("val.allowlistRange", { entry })); throw new Error("invalid-field"); } if (seen.has(id.toString())) { setFieldError(input, t("val.allowlistDup", { entry })); throw new Error("invalid-field"); } seen.add(id.toString()); ids.push(id); } return ids; } function validateHttpsUrl(input, label, { noCredentials = false, noQuery = false } = {}) { let parsed; try { parsed = new URL(input.value.trim()); } catch { setFieldError(input, t("val.httpsUrl", { label })); throw new Error("invalid-field"); } if (parsed.protocol !== "https:" || !parsed.hostname) { setFieldError(input, t("val.https", { label })); throw new Error("invalid-field"); } if (noCredentials && (parsed.username || parsed.password)) { setFieldError(input, t("val.noCreds", { label })); throw new Error("invalid-field"); } const rawValue = input.value.trim(); if (noQuery && (rawValue.includes("?") || rawValue.includes("#"))) { setFieldError(input, t("val.noQuery", { label })); throw new Error("invalid-field"); } return input.value.trim(); } function requireText(id, label, secret = false) { const input = document.getElementById(id); const value = secret ? input.value : input.value.trim(); if (isBlank(value)) { setFieldError(input, t("val.empty", { label })); throw new Error("invalid-field"); } return value; } function readBusinessConfig() { clearCustomValidity(businessForm); const enabled = llmEnabled.checked; llmApiKey.required = enabled; llmBaseUrl.required = enabled; llmModel.required = enabled; try { const llmBaseValue = llmBaseUrl.value.trim(); const llmModelValue = llmModel.value.trim(); const llmKeyValue = llmApiKey.value; if (enabled && !llmBaseValue) return setFieldError(llmBaseUrl, t("val.llmUrl")); if (enabled && !llmModelValue) return setFieldError(llmModel, t("val.llmModel")); if (enabled && isBlank(llmKeyValue)) return setFieldError(llmApiKey, t("val.llmKey")); const llmBase = llmBaseValue ? (enabled ? validateHttpsUrl(llmBaseUrl, "LLM Base URL") : llmBaseValue) : null; const jmapUrlInput = document.getElementById("jmap-session-url"); const business = { bot_token: requireText("telegram-bot-token", "Telegram Bot Token", true), telegram_chat_id: parseI64(document.getElementById("telegram-chat-id"), t("telegram.chatLabel")), chat_allowlist: parseAllowlist(document.getElementById("chat-allowlist")), telegram_webhook_secret: requireText("telegram-webhook-secret", "Telegram Webhook Secret", true), jmap_session_url: validateHttpsUrl(jmapUrlInput, "JMAP Session URL", { noCredentials: true, noQuery: true }), jmap_username: requireText("jmap-username", t("jmap.userLabel")), jmap_password: requireText("jmap-password", t("jmap.passLabel"), true), llm_enabled: enabled, llm_allow_net: document.getElementById("llm-allow-net").checked, llm_api_key: isBlank(llmKeyValue) ? null : llmKeyValue, llm_base_url: llmBase, llm_model: llmModelValue || null, reconcile_token: requireText("reconcile-token", "Reconcile Token", true), worker_token: requireText("worker-token", "Worker Token", true), timezone: document.getElementById("timezone").value, }; return business; } catch (error) { if (error.message === "invalid-field") return null; throw error; } } function stringifyWithBigInt(value) { if (typeof value === "bigint") return value.toString(); if (Array.isArray(value)) return `[${value.map(stringifyWithBigInt).join(",")}]`; if (value !== null && typeof value === "object") { return `{${Object.entries(value).map(([key, item]) => `${JSON.stringify(key)}:${stringifyWithBigInt(item)}`).join(",")}}`; } return JSON.stringify(value); } function businessErrorMessage(error) { if (error.status === 401) return t("berr.401"); if (error.status === 400) return t("berr.400"); if (error.status === 409) return t("berr.409"); if (error.status === 422) return t("berr.422"); if (error.status === 503) return t("berr.503"); if (error.status !== undefined) return t("berr.status", { status: error.status }); return t("berr.fail", { message: error.message || t("berr.failDefault") }); } function runtimePayloadFromForm() { clearCustomValidity(runtimeForm); const config = {}; for (const [key, id] of RUNTIME_FIELDS) { const input = document.getElementById(id); const value = input.value.trim() === "" ? Number.NaN : Number(input.value); const limit = RUNTIME_LIMITS[key]; if (!Number.isInteger(value)) return setFieldError(input, t("err.invalidInt")), null; if (value < limit.min || value > limit.max) { return setFieldError(input, t("err.rangeInt", { min: limit.min, max: limit.max })), null; } config[key] = value; } return config; } function updateLLMRequirements() { const enabled = llmEnabled.checked; llmApiKey.required = enabled; llmBaseUrl.required = enabled; llmModel.required = enabled; const mark = enabled ? t("common.required") : t("common.optional"); document.querySelector("#llm-key-required").textContent = mark; document.querySelector("#llm-url-required").textContent = mark; document.querySelector("#llm-model-required").textContent = mark; } // --- Business-config readback (GET /api/business-config) ---------------- // // The server stores a BusinessConfig in Redis. Re-logging in must not force // the operator to re-enter everything, so the SPA reads the saved config back // after a session is created, shows which secrets are already stored, pre-fills // the non-secret fields, and submits only the fields the operator changed. // // Secret values are never echoed by the backend (charter SAF-NO-SECRET-ECHO), // so a secret field is always blank. A blank secret therefore means "keep the // stored value" and is omitted from the patch; a non-blank one replaces it. function isRecord(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function resetBusinessReadback() { businessBaseline = null; businessRevision = null; businessSecretPresence = null; renderSecretMarkers(); } function renderSecretMarkers() { for (const field of SECRET_FIELDS) { const marker = document.getElementById(`secret-marker-${field.key}`); if (!marker) continue; const input = document.getElementById(field.id); const presence = Boolean(businessSecretPresence && businessSecretPresence[field.key]); const blank = !input || isBlank(input.value); const state = presence ? t("field.set") : t("field.unset"); const hint = !presence ? t("secret.mustEnter") : (blank ? t("secret.keepSet") : t("secret.changed")); marker.textContent = t("secret.marked", { state, hint }); marker.classList.toggle("is-unset", !presence); } } function applyBusinessReadback(readback) { businessBaseline = isRecord(readback && readback.values) ? readback.values : {}; businessRevision = Number.isInteger(readback && readback.revision) ? readback.revision : null; businessSecretPresence = isRecord(readback && readback.secrets_present) ? readback.secrets_present : {}; for (const field of PLAIN_TEXT_FIELDS) { const input = document.getElementById(field.id); if (!input) continue; const value = businessBaseline[field.key]; input.value = typeof value === "string" ? value : ""; } // telegram_chat_id is a single numeric value, not a list, so it gets its own // treatment rather than living in PLAIN_TEXT_FIELDS. const chatIdInput = document.getElementById("telegram-chat-id"); if (chatIdInput) { const value = businessBaseline.telegram_chat_id; chatIdInput.value = typeof value === "string" ? value : (value === undefined ? "" : String(value)); } const allowlistValue = businessBaseline.chat_allowlist; const allowlistInput = document.getElementById("chat-allowlist"); if (allowlistInput) { // Comma-separated, not `joinList`: `parseAllowlist` only splits on commas // and whitespace, so a locale separator would parse as one invalid entry. allowlistInput.value = Array.isArray(allowlistValue) ? allowlistValue.join(", ") : ""; } for (const field of PLAIN_BOOL_FIELDS) { const input = document.getElementById(field.id); if (input) input.checked = businessBaseline[field.key] === true; } updateLLMRequirements(); // A stored configuration makes every secret optional: blank keeps the stored // value, so native `required` would wrongly block a partial save. Same for the // LLM text fields, which are already stored server-side. Apply this after the // toggle above so a change-event handler cannot re-enable it. for (const field of SECRET_FIELDS) { const input = document.getElementById(field.id); if (!input) continue; input.value = ""; input.required = false; // The original placeholder says "re-enter on each full save"; with a stored // configuration the common action is to leave the field blank, so swap it. input.placeholder = t("secret.keepEmpty"); } llmApiKey.required = false; llmBaseUrl.required = false; llmModel.required = false; renderSecretMarkers(); } // Build the changed-fields patch from the pre-filled form. Blank secrets are // omitted (keep the stored value); text fields are omitted when they still // match the baseline. Returns null after marking a field invalid. function businessPatchFromForm() { clearCustomValidity(businessForm); const baseline = isRecord(businessBaseline) ? businessBaseline : {}; const patch = {}; // Optimistic lock: which saved version this page read. It is a control // field, so the empty-patch check below deliberately ignores it. if (Number.isInteger(businessRevision)) patch.revision = businessRevision; try { for (const field of SECRET_FIELDS) { const input = document.getElementById(field.id); if (!input) continue; const value = input.value.trim(); if (value !== "") patch[field.key] = value; } // LLM text fields (base URL, model) and the LLM API key are handled by the // loops below like any other field: they are compared to the stored values, // and the key is submitted only when the operator typed a new one. Turning // LLM off needs no text change — the flag alone is enough, and the stored // URL and model simply stop being used. if (llmEnabled.checked !== Boolean(baseline.llm_enabled)) { patch.llm_enabled = llmEnabled.checked; } const allowNetInput = document.getElementById("llm-allow-net"); if (allowNetInput && allowNetInput.checked !== Boolean(baseline.llm_allow_net)) { patch.llm_allow_net = allowNetInput.checked; } for (const field of PLAIN_TEXT_FIELDS) { const input = document.getElementById(field.id); if (!input) continue; const value = input.value.trim(); const stored = baseline[field.key]; // A required text field blank means "leave the stored value alone", so a // partial save can never accidentally delete one. if (value === "" && !field.optional) continue; // Clearing an optional field only matters when there was a value to clear; // an empty-and-absent field is already null on the server. if (value === "" && (stored === undefined || stored === null || stored === "")) continue; if (value === stored) continue; if (field.https && !validateHttpsUrl(input, field.https)) return null; patch[field.key] = value === "" ? null : value; } const allowlistInput = document.getElementById("chat-allowlist"); if (!isBlank(allowlistInput.value)) { const allowlist = parseAllowlist(allowlistInput); const savedList = Array.isArray(baseline.chat_allowlist) ? baseline.chat_allowlist.map((item) => String(item)) : []; const nextList = allowlist.map((item) => item.toString()); const fingerprint = (values) => values.slice().sort().join(","); if (fingerprint(nextList) !== fingerprint(savedList)) { patch.chat_allowlist = allowlist; } } const chatIdInput = document.getElementById("telegram-chat-id"); if (!isBlank(chatIdInput.value)) { const chatId = parseI64(chatIdInput, t("telegram.chatLabel")).toString(); if (chatId !== String(baseline.telegram_chat_id ?? "")) patch.telegram_chat_id = chatId; } return patch; } catch (error) { if (error.message === "invalid-field") return null; throw error; } } async function loadBusinessConfig(options = {}) { const { toast = true } = options; try { const readback = await request("/api/business-config", "GET"); if (readback && readback.configured === true) { applyBusinessReadback(readback); if (toast) showNotice("info", t("session.readbackToast")); } else { resetBusinessReadback(); updateLLMRequirements(); if (toast) showNotice("info", t("session.firstConfigToast")); } return true; } catch (error) { if (error instanceof ApiError && error.status === 401) { expireSession(t("session.invalidBusiness")); return false; } if (error instanceof ApiError && error.status === 503) { showNotice("error", t("session.readback503")); return false; } showNotice("error", t("session.readbackFailed")); return false; } } authForm.addEventListener("submit", async (event) => { event.preventDefault(); if (busyAction) return; let bootstrapCredential = bootstrapInput.value; bootstrapInput.value = ""; if (isBlank(bootstrapCredential)) { showNotice("error", t("login.enterCred")); bootstrapInput.focus(); return; } setBusy("login"); showNotice("info", t("login.creatingToast")); try { const sessionInfo = await request("/api/admin/session", "POST", undefined, bootstrapCredential); bootstrapCredential = ""; if (!sessionInfo || typeof sessionInfo.session !== "string" || !sessionInfo.session) { throw new Error(t("login.invalidResponse")); } adminSession = sessionInfo.session; scheduleSessionExpiry(sessionInfo.expires_in); showConfigView(); businessFields.disabled = false; businessSaveState.textContent = t("business.saveStateReady"); showRuntimePreview(); setBusy(""); showNotice("success", t("login.successToast")); await loadRuntimeConfig(); await loadEnabled(); await loadBusinessConfig(); } catch (error) { bootstrapCredential = ""; setBusy(""); if (error.status === 401) showNotice("error", t("login.401")); else if (error.status === 503) showNotice("error", t("login.503")); else if (error.status !== undefined) showNotice("error", t("login.status", { status: error.status })); else showNotice("error", error.message || t("login.fail")); bootstrapInput.focus(); } }); reloadButton.addEventListener("click", loadRuntimeConfig); serviceEnabled.addEventListener("change", async () => { if (!adminSession || busyAction) return; const desired = serviceEnabled.checked; serviceEnabled.disabled = true; try { await request("/api/enabled", "PUT", { enabled: desired }); serviceEnabledState.textContent = desired ? t("service.enabled") : t("service.disabled"); showNotice("success", desired ? t("service.enabledToast") : t("service.disabledToast")); } catch (error) { serviceEnabled.checked = !desired; serviceEnabledState.textContent = serviceEnabled.checked ? t("service.enabled") : t("service.disabled"); showNotice("error", error.status === 401 ? t("err.sessionInvalid") : t("service.saveFail")); } finally { serviceEnabled.disabled = false; } }); logoutButton.addEventListener("click", async () => { if (!adminSession || busyAction) return; setBusy("logout"); const session = adminSession; try { await request("/api/admin/session/revoke", "POST", undefined, session); } catch { // Local credentials are cleared even when the revocation request cannot reach Redis. } clearSession(); showNotice("info", t("session.revoked")); }); statusRetryButton.addEventListener("click", showSetupStatus); defaultsButton.addEventListener("click", () => { if (!runtimeLoaded || busyAction) return; fillRuntimeFields(RUNTIME_DEFAULTS); runtimeSaveState.textContent = t("runtime.defaultsState"); runtimeSaveState.classList.add("is-dirty"); showNotice("info", t("runtime.defaultsToast")); }); businessForm.addEventListener("input", (event) => { event.target.setCustomValidity(""); // A typed secret value replaces the stored one; a cleared one restores it. if (businessSecretPresence) renderSecretMarkers(); if (!adminSession) return; businessSaveState.textContent = t("business.dirty"); businessSaveState.classList.add("is-dirty"); }); runtimeForm.addEventListener("input", (event) => { event.target.setCustomValidity(""); if (!runtimeLoaded) return; runtimeSaveState.textContent = t("runtime.dirty"); runtimeSaveState.classList.add("is-dirty"); }); llmEnabled.addEventListener("change", updateLLMRequirements); // "Test connection" posts to the preflight endpoint, which never writes: the previous // configuration stays live either way. It reuses readBusinessConfig() so preflight and save // can never disagree about whether the form is complete. preflightButton.addEventListener("click", runBusinessPreflight); callbackRegisterButton.addEventListener("click", async () => { if (!adminSession || busyAction) return; if (businessBaseline === null || businessSaveState.classList.contains("is-dirty")) { callbackState.textContent = t("callback.saveFirst"); return; } let origin; try { origin = new URL(callbackOrigin.value.trim()); if (origin.protocol !== "https:" || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash) throw new Error(); } catch { callbackState.textContent = t("callback.invalidOrigin"); callbackOrigin.focus(); return; } const base = origin.origin; setBusy("callback-register"); const results = { telegram: false, stalwart: false }; try { try { await request("/api/telegram/register-webhook", "POST", { callback_url: `${base}/webhook/tg` }); results.telegram = true; } catch (error) { if (error.status === 401) { expireSession(t("session.invalidBusiness")); return; } } try { await request("/api/push/register", "POST", { callback_url: `${base}/push/jmap` }); results.stalwart = true; } catch (error) { if (error.status === 401) { expireSession(t("session.invalidBusiness")); return; } } if (results.telegram && results.stalwart) { callbackState.textContent = t("callback.done"); showNotice("success", t("callback.done")); } else { const text = t("callback.partial", { telegram: results.telegram ? t("callback.telegramOk") : t("callback.failed"), stalwart: results.stalwart ? t("callback.pushOk") : t("callback.failed"), }); callbackState.textContent = text; showNotice("error", text); } } finally { setBusy(""); } }); async function runBusinessPreflight() { if (!adminSession || busyAction) return; const config = readBusinessConfig(); if (!config) return; setBusy("business-preflight"); businessSaveState.textContent = t("business.preflightRunning"); businessSaveState.classList.remove("is-ok", "is-warn"); try { const result = await request("/api/business-config/preflight", "POST", stringifyWithBigInt(config)); const verdict = describePreflight(result); businessSaveState.textContent = verdict.text; businessSaveState.classList.remove("is-dirty"); businessSaveState.classList.add(verdict.tone === "ok" ? "is-ok" : "is-warn"); showNotice(verdict.tone === "ok" ? "success" : "info", verdict.text); updateButtons(); } catch (error) { if (error.status === 401) { expireSession(t("session.invalidBusiness")); return; } businessSaveState.textContent = t("business.preflightFailed"); businessSaveState.classList.add("is-warn"); showNotice("error", businessErrorMessage(error)); } finally { if (adminSession) setBusy(""); } } // Preflight answers three distinct questions and they must stay separate in the UI: is the // form valid, and if so, which *component* failed to build? A single boolean would hide the // second failure behind the first. function describePreflight(result) { if (result && result.persisted === true) { return { tone: "warn", text: t("business.preflightPersisted") }; } const validation = result && result.validation; if (validation && validation.ok === false) { const details = Array.isArray(validation.errors) ? validation.errors.filter(Boolean).join(" · ") : ""; return { tone: "warn", text: [t("business.preflightInvalid"), details].filter(Boolean).join(" ") }; } const components = result && result.components; if (!components) return { tone: "warn", text: t("business.preflightUnknown") }; const failed = Object.entries(components) .filter(([, component]) => component && component.ok === false) .map(([name, component]) => { const errors = Array.isArray(component.errors) ? component.errors.map((item) => `${item.step || "build"}: ${item.detail || ""}`.trim()) : []; return [name, errors.filter(Boolean).join(" · ")].filter(Boolean).join(" "); }); if (!failed.length) return { tone: "ok", text: t("business.preflightOk") }; return { tone: "warn", text: [t("business.preflightPartial"), failed.join(" | ")].filter(Boolean).join(" ") }; } businessForm.addEventListener("submit", async (event) => { event.preventDefault(); if (!adminSession || busyAction) return; // With a stored configuration the form is pre-filled, so only the changed // fields are sent. Without one, every field is required as before. const patch = businessBaseline === null ? readBusinessConfig() : businessPatchFromForm(); if (!patch) return; // `revision` is a control field, never a setting, so it never makes the // page count as changed. const settingKeys = Object.keys(patch).filter((key) => key !== "revision"); if (businessBaseline !== null && settingKeys.length === 0) { businessSaveState.textContent = t("business.noChange"); businessSaveState.classList.remove("is-dirty"); return; } setBusy("business-save"); showNotice("info", t("business.submittingToast")); try { const result = await request("/api/business-config", "PUT", stringifyWithBigInt(patch)); clearSecretFields(); // Saving and applying are separate outcomes: the backend persists a validated // configuration even when no client could be built, so "saved" alone would overclaim. const warnings = result && Array.isArray(result.warnings) ? result.warnings : []; if (result && result.runtime_applied === false && warnings.length) { const details = warnings .map((item) => [item.component, item.step, item.detail].filter(Boolean).join(": ")) .filter(Boolean); const text = [t("business.savedWarning"), details.join(" | ")].filter(Boolean).join(" "); businessSaveState.textContent = text; businessSaveState.classList.remove("is-dirty"); businessSaveState.classList.add("is-warn"); showNotice("info", text); } else { const revision = typeof result && typeof result.revision === "number" ? t("business.savedRevision", { revision: result.revision }) : ""; const text = [t("business.saved"), revision].filter(Boolean).join(" "); businessSaveState.textContent = text; businessSaveState.classList.remove("is-dirty"); showNotice("success", text); } updateButtons(); // Refresh the authoritative baseline so the markers and pre-filled values // reflect what was just stored. No toast: the save confirmation is enough. await loadBusinessConfig({ toast: false }); } catch (error) { if (error.status === 401) { expireSession(t("session.invalidBusiness")); return; } if (error.status === 409) { businessSaveState.textContent = t("business.conflict"); showNotice("error", businessErrorMessage(error)); // Re-read the stored configuration. Without this the page would keep // submitting against a revision the backend no longer has, so every // retry would fail the same way. try { await loadBusinessConfig({ toast: false }); } catch (reloadError) { businessRevision = null; void reloadError; } } else { businessSaveState.textContent = t("business.unsaved"); showNotice("error", businessErrorMessage(error)); } } finally { if (adminSession) setBusy(""); } }); runtimeForm.addEventListener("submit", async (event) => { event.preventDefault(); if (!adminSession || !runtimeLoaded || busyAction) return; const config = runtimePayloadFromForm(); if (!config) return; setBusy("runtime-save"); showNotice("info", t("runtime.savingToast")); try { const saved = normalizeRuntimeConfig(await request("/api/config", "PUT", config)); activateRuntimeConfig(saved); showNotice("success", t("runtime.savedToast")); } catch (error) { if (error.status === 401) { expireSession(t("session.invalidBusiness")); return; } if (error.status >= 500 || error.status === undefined) { runtimeLoaded = false; runtimeFields.disabled = true; runtimeSaveState.textContent = t("runtime.saveUnconfirmed"); updateButtons(); } showNotice("error", runtimeErrorMessage(error, "save")); } finally { if (adminSession) setBusy(""); } }); window.addEventListener("pagehide", () => { adminSession = ""; if (expiryTimer) window.clearTimeout(expiryTimer); clearForms(); clearSecretFields(); configPanel.hidden = true; authCard.hidden = setupMode; bootstrapInput.value = ""; setBusy(""); }); showRuntimePreview(); showSetupStatus(); updateLLMRequirements(); updateButtons(); // Expose a tiny debug surface for runtime inspection and tests. Declared last // so it can close over every state variable, including the business-config // readback baseline. if (typeof window !== "undefined") { window.__mw = { t, detectLocale, getLocale: () => locale, messages: MESSAGES, joinList, timezones: TIMEZONES, defaultTimezone: DEFAULT_TIMEZONE, readBusinessConfig, applyBusinessReadback, businessPatchFromForm, renderSecretMarkers, resetBusinessReadback, get businessBaseline() { return businessBaseline; }, get businessRevision() { return businessRevision; }, get businessSecretPresence() { return businessSecretPresence; }, fields: { secrets: SECRET_FIELDS, text: PLAIN_TEXT_FIELDS, bool: PLAIN_BOOL_FIELDS }, }; } })();