diff --git a/deploy/LightBridge-mail-service.service b/deploy/LightBridge-mail-service.service new file mode 100644 index 0000000..8627895 --- /dev/null +++ b/deploy/LightBridge-mail-service.service @@ -0,0 +1,21 @@ +[Unit] +Description=LightBridge Mail Service +After=network.target LightBridge.service +Wants=network.target + +[Service] +Type=simple +User=LightBridge +Group=LightBridge +WorkingDirectory=/opt/LightBridge +EnvironmentFile=-/etc/LightBridge/mail-service.env +ExecStart=/opt/LightBridge/lightbridge-mail-service +Restart=always +RestartSec=5s +LimitNOFILE=100000 +NoNewPrivileges=true +PrivateTmp=true +ReadWritePaths=/var/lib/LightBridge/mail-service + +[Install] +WantedBy=multi-user.target diff --git a/deploy/docker-compose.mail-service.yml b/deploy/docker-compose.mail-service.yml new file mode 100644 index 0000000..9cfe60d --- /dev/null +++ b/deploy/docker-compose.mail-service.yml @@ -0,0 +1,33 @@ +services: + mail-service: + build: + context: ../mailservice + dockerfile: Dockerfile + container_name: LightBridge-mail-service + restart: unless-stopped + ports: + - "${LBMS_BIND_HOST:-127.0.0.1}:${LBMS_PORT:-8091}:8091" + environment: + LBMS_HOST: 0.0.0.0 + LBMS_PORT: 8091 + LBMS_API_KEY: ${LBMS_API_KEY:?LBMS_API_KEY is required} + LBMS_DATA_PATH: /data/lbms-store.json + LBMS_DRIVER: ${LBMS_DRIVER:-outlook_email_plus} + LBMS_DRIVER_BASE_URL: ${LBMS_DRIVER_BASE_URL:-http://outlook-mail-plus:5000} + LBMS_DRIVER_API_KEY: ${LBMS_DRIVER_API_KEY:?LBMS_DRIVER_API_KEY is required} + LBMS_REQUEST_TIMEOUT_SECONDS: ${LBMS_REQUEST_TIMEOUT_SECONDS:-10} + LBMS_VERIFICATION_CACHE_SECONDS: ${LBMS_VERIFICATION_CACHE_SECONDS:-30} + LIGHTBRIDGE_BASE_URL: ${LIGHTBRIDGE_BASE_URL:-http://LightBridge:8080} + volumes: + - mail_service_data:/data + depends_on: + - LightBridge + networks: + - LightBridge-network + +volumes: + mail_service_data: + +networks: + LightBridge-network: + driver: bridge diff --git a/deploy/install-mail-service.sh b/deploy/install-mail-service.sh new file mode 100644 index 0000000..7ae7ba8 --- /dev/null +++ b/deploy/install-mail-service.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +SERVICE_NAME="LightBridge-mail-service" +INSTALL_DIR="/opt/LightBridge" +DATA_DIR="/var/lib/LightBridge/mail-service" +ENV_DIR="/etc/LightBridge" +ENV_FILE="${ENV_DIR}/mail-service.env" +SYSTEMD_FILE="/etc/systemd/system/${SERVICE_NAME}.service" +BINARY_SOURCE="" + +usage() { + cat <<'USAGE' +Install LightBridge Mail Service as an optional systemd sidecar. + +Usage: + sudo ./install-mail-service.sh --binary ./lightbridge-mail-service + +Options: + --binary PATH Path to a prebuilt lightbridge-mail-service binary. + -h, --help Show help. +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --binary) + BINARY_SOURCE="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "${BINARY_SOURCE}" ]]; then + echo "--binary is required" >&2 + usage + exit 1 +fi + +if [[ ! -f "${BINARY_SOURCE}" ]]; then + echo "Binary not found: ${BINARY_SOURCE}" >&2 + exit 1 +fi + +if [[ "${EUID}" -ne 0 ]]; then + echo "Please run as root or with sudo." >&2 + exit 1 +fi + +if ! id -u LightBridge >/dev/null 2>&1; then + useradd --system --home-dir "${INSTALL_DIR}" --shell /usr/sbin/nologin LightBridge +fi + +mkdir -p "${INSTALL_DIR}" "${DATA_DIR}" "${ENV_DIR}" +install -m 0755 "${BINARY_SOURCE}" "${INSTALL_DIR}/lightbridge-mail-service" +chown -R LightBridge:LightBridge "${DATA_DIR}" + +if [[ ! -f "${ENV_FILE}" ]]; then + cat > "${ENV_FILE}" <<'ENV' +LBMS_HOST=0.0.0.0 +LBMS_PORT=8091 +LBMS_API_KEY=change-me-to-a-long-random-value +LBMS_DATA_PATH=/var/lib/LightBridge/mail-service/lbms-store.json +LBMS_DRIVER=outlook_email_plus +LBMS_DRIVER_BASE_URL=http://127.0.0.1:5000 +LBMS_DRIVER_API_KEY=change-me-driver-key +LBMS_REQUEST_TIMEOUT_SECONDS=10 +LBMS_VERIFICATION_CACHE_SECONDS=30 +LIGHTBRIDGE_BASE_URL=http://127.0.0.1:8080 +ENV + chmod 0600 "${ENV_FILE}" +fi + +cat > "${SYSTEMD_FILE}" <<'UNIT' +[Unit] +Description=LightBridge Mail Service +After=network.target LightBridge.service +Wants=network.target + +[Service] +Type=simple +User=LightBridge +Group=LightBridge +WorkingDirectory=/opt/LightBridge +EnvironmentFile=-/etc/LightBridge/mail-service.env +ExecStart=/opt/LightBridge/lightbridge-mail-service +Restart=always +RestartSec=5s +LimitNOFILE=100000 +NoNewPrivileges=true +PrivateTmp=true +ReadWritePaths=/var/lib/LightBridge/mail-service + +[Install] +WantedBy=multi-user.target +UNIT + +systemctl daemon-reload +systemctl enable "${SERVICE_NAME}.service" + +echo "Installed ${SERVICE_NAME}." +echo "Edit ${ENV_FILE}, then start with: sudo systemctl start ${SERVICE_NAME}.service" diff --git a/deploy/mail-service.env.example b/deploy/mail-service.env.example new file mode 100644 index 0000000..08631ac --- /dev/null +++ b/deploy/mail-service.env.example @@ -0,0 +1,17 @@ +# LightBridge Mail Service optional sidecar configuration +# Copy this file to /etc/LightBridge/mail-service.env for systemd deployments, +# or export the variables before using deploy/docker-compose.mail-service.yml. + +LBMS_HOST=0.0.0.0 +LBMS_PORT=8091 +LBMS_API_KEY=change-me-to-a-long-random-value + +# Internal driver configuration. Keep these values server-side only. +LBMS_DRIVER=outlook_email_plus +LBMS_DRIVER_BASE_URL=http://127.0.0.1:5000 +LBMS_DRIVER_API_KEY=change-me-driver-key + +LBMS_REQUEST_TIMEOUT_SECONDS=10 +LBMS_VERIFICATION_CACHE_SECONDS=30 + +LIGHTBRIDGE_BASE_URL=http://127.0.0.1:8080 diff --git a/docs/LIGHTBRIDGE_MAIL_SERVICE_PLAN_CN.md b/docs/LIGHTBRIDGE_MAIL_SERVICE_PLAN_CN.md new file mode 100644 index 0000000..69c3f02 --- /dev/null +++ b/docs/LIGHTBRIDGE_MAIL_SERVICE_PLAN_CN.md @@ -0,0 +1,1129 @@ +# LightBridge Mail Service 长期实施计划 + +> 目标:为 LightBridge 增加一个可选安装、轻量外置、品牌统一、可长期稳定运行的邮件服务,用于关联账户管理中的 OAuth 账户、邮箱池管理、邮件读取、验证码/验证链接获取,以及后续自动化注册流程辅助。 + +--- + +## 0. 背景与最终命名 + +本方案中的所有用户可见名称统一为 **LightBridge Mail Service**,简称 **LBMS**。 + +底层可以集成 OutlookMail Plus / Outlook Email Plus,但这些名称只允许出现在 LBMS 内部 driver 配置、日志调试字段和开发者文档中。LightBridge 管理后台、API、账号 Extra 字段、环境变量、用户帮助文案、按钮和错误提示中,都不应出现 `Outlook Mail Pulse`、`OutlookMail Plus` 或其他底层服务品牌名。 + +统一命名规则: + +| 场景 | 推荐命名 | +|---|---| +| 产品名称 | LightBridge Mail Service | +| 简称 | LBMS | +| 后端服务名 | `lightbridge-mail-service` | +| systemd 服务 | `LightBridge-mail-service.service` | +| Docker service | `mail-service` | +| API 前缀 | `/mail/v1` | +| OAuth 账户 Extra 字段 | `lbms_link` | +| 邮箱引用 URI | `lbms://mailbox/{mailbox_id}` | +| 底层驱动字段 | `driver: outlook_email_plus`,仅内部可见 | + +--- + +## 1. 已确认的 LightBridge 现状约束 + +### 1.1 主体项目定位 + +LightBridge 是一个 AI API Gateway Platform,支持 systemd-friendly binary deployment、安装、升级、回滚和从 Sub2API 迁移。 + +### 1.2 部署形态 + +当前部署文件已经支持 Docker Compose 与二进制 systemd 两种路径。Docker Compose 以 LightBridge、PostgreSQL、Redis 为核心服务,并通过环境变量完成配置。LBMS 必须遵守这个模式:默认安装 LightBridge 时不附带邮件服务,只在用户主动启用时额外部署。 + +### 1.3 数据库和迁移约束 + +LightBridge 使用 PostgreSQL,迁移文件按顺序执行,并通过 `schema_migrations` 记录文件名和 checksum。已经应用过的 migration 不应修改,只能新增前向迁移。LBMS 第一阶段不应改动 LightBridge 主数据库 schema,只使用现有 `accounts.extra` 放一个极简链接。 + +### 1.4 Account schema 约束 + +LightBridge 的 `accounts` 表已有: + +- `platform`:例如 `openai`、`gemini`、`anthropic`。 +- `type`:例如 `oauth`、`api_key`、`cookie`。 +- `credentials`:JSONB,用于凭证。 +- `extra`:JSONB,用于平台扩展信息。 + +LBMS 只能在 `extra` 中写入非常小的引用,例如: + +```json +{ + "lbms_link": "lbms://mailbox/mbx_01JABCDEF1234567890" +} +``` + +禁止在 LightBridge 主体 `extra` 里存放完整邮箱资料、底层 provider 配置、最近验证码、邮件内容、IMAP 参数、底层账号 ID、底层 API Key、同步状态详情等。 + +--- + +## 2. 总体架构 + +### 2.1 第一阶段推荐架构:外置 sidecar + +```text +LightBridge 主服务 + ├─ OAuth 账户管理 + ├─ 原有 API Key / Admin JWT / 用户与分组体系 + ├─ 模块 UI 入口 + └─ accounts.extra.lbms_link 只保存极简邮箱链接 + +LightBridge Mail Service sidecar + ├─ 自己的数据库 + ├─ 邮箱实体 + ├─ 邮箱池 + ├─ OAuth 账户绑定关系 + ├─ 验证码/验证链接获取 + ├─ 反向索引:邮箱 -> 多个 OAuth 账户 + ├─ LightBridge API Key 校验适配层 + └─ 底层 driver:outlook_email_plus + +底层邮件服务 + └─ OutlookMail Plus / Outlook Email Plus +``` + +### 2.2 为什么不直接改内核 + +当前目标是轻量外接,不改变内核。LBMS 第一阶段不能把邮件系统直接写进 LightBridge 主进程,也不能把底层邮件服务的表强塞到 LightBridge 主数据库。 + +第一阶段只做: + +1. 一个独立运行的 LBMS sidecar。 +2. 一个 LightBridge 前端模块或管理 UI 入口。 +3. OAuth Account `extra.lbms_link` 的极简引用。 +4. 可选反向代理,让用户通过同域 `/mail/v1` 访问 LBMS。 + +第二阶段如确实需要原生后端模块路由,再单独设计 LightBridge 内核能力:`backend.http.route` 或 `extension.route`,不能把这个改动混在第一阶段。 + +--- + +## 3. 核心产品原则 + +### 3.1 品牌统一 + +用户看到的永远是 LightBridge Mail Service。所有表单字段、设置页、API 文档、错误提示、日志中的普通级别信息,都用 LBMS 术语。 + +示例: + +```text +正确:LightBridge Mail Service 连接失败 +错误:OutlookMail Plus 连接失败 + +正确:邮箱服务 API Key 无效 +错误:Outlook Email Plus X-API-Key invalid +``` + +底层 driver 错误只允许在 debug 日志中出现,并且要脱敏。 + +### 3.2 主体数据库轻量 + +LightBridge 主库只保存: + +```json +{ + "lbms_link": "lbms://mailbox/mbx_xxx" +} +``` + +所有详细信息都归 LBMS 自己管理。 + +### 3.3 双向连接 + +必须支持: + +- 从 OAuth 账号找到邮箱。 +- 从邮箱找到所有绑定的 OAuth 账号。 + +### 3.4 一个邮箱绑定多个 OAuth + +一个邮箱可以绑定多个 OAuth 账户,例如: + +```text +aa@qq.com + ├─ OpenAI OAuth #101 + ├─ OpenAI OAuth #102 + ├─ Gemini OAuth #205 + └─ Anthropic OAuth #311 +``` + +第一阶段规则: + +- 一个 OAuth 账户最多绑定一个邮箱。 +- 一个邮箱可以绑定多个 OAuth 账户。 +- 绑定关系由 LBMS 数据库维护。 + +--- + +## 4. 数据模型设计 + +### 4.1 LightBridge 主体账号 Extra + +OAuth 账户 extra 只保存: + +```json +{ + "lbms_link": "lbms://mailbox/mbx_01JABCDEF1234567890" +} +``` + +可选保留版本: + +```json +{ + "lbms_link": "lbms://mailbox/mbx_01JABCDEF1234567890", + "lbms_link_version": 1 +} +``` + +不建议第一版添加更多字段。 + +### 4.2 LBMS 自有数据库表 + +LBMS 可以使用 SQLite 或 PostgreSQL。为长期稳定,推荐: + +- Docker Compose 场景:优先 PostgreSQL,可复用 LightBridge PostgreSQL 但使用独立 schema 或独立 database。 +- 二进制单机场景:允许 SQLite,便于轻量部署。 +- 生产建议:PostgreSQL。 + +#### `lbms_mailboxes` + +```sql +CREATE TABLE lbms_mailboxes ( + id TEXT PRIMARY KEY, + email_address TEXT NOT NULL, + normalized_email TEXT NOT NULL, + display_name TEXT, + status TEXT NOT NULL DEFAULT 'active', + source_driver TEXT NOT NULL, + source_mailbox_ref TEXT, + source_project_key TEXT, + tags JSONB NOT NULL DEFAULT '[]', + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX lbms_mailboxes_normalized_email_active_unique + ON lbms_mailboxes(normalized_email) + WHERE deleted_at IS NULL; +``` + +#### `lbms_oauth_bindings` + +```sql +CREATE TABLE lbms_oauth_bindings ( + id TEXT PRIMARY KEY, + mailbox_id TEXT NOT NULL REFERENCES lbms_mailboxes(id), + lightbridge_account_id BIGINT NOT NULL, + lightbridge_platform TEXT NOT NULL, + lightbridge_account_type TEXT NOT NULL, + lightbridge_account_name TEXT, + status TEXT NOT NULL DEFAULT 'active', + created_by BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX lbms_oauth_bindings_account_active_unique + ON lbms_oauth_bindings(lightbridge_account_id) + WHERE deleted_at IS NULL; + +CREATE INDEX lbms_oauth_bindings_mailbox_id_idx + ON lbms_oauth_bindings(mailbox_id) + WHERE deleted_at IS NULL; +``` + +含义: + +- `lightbridge_account_id` 在 active binding 中唯一。 +- `mailbox_id` 不唯一,所以一个邮箱可绑定多个 OAuth。 + +#### `lbms_mail_events` + +用于审计邮件读取、验证码获取、验证链接获取。 + +```sql +CREATE TABLE lbms_mail_events ( + id TEXT PRIMARY KEY, + mailbox_id TEXT NOT NULL, + lightbridge_account_id BIGINT, + event_type TEXT NOT NULL, + request_id TEXT, + actor_type TEXT NOT NULL, + actor_id TEXT, + success BOOLEAN NOT NULL, + error_code TEXT, + latency_ms INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX lbms_mail_events_mailbox_created_idx + ON lbms_mail_events(mailbox_id, created_at DESC); +``` + +#### `lbms_driver_accounts` + +用于隐藏底层邮件服务细节。 + +```sql +CREATE TABLE lbms_driver_accounts ( + id TEXT PRIMARY KEY, + driver TEXT NOT NULL, + base_url TEXT NOT NULL, + encrypted_api_key TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + health_status TEXT, + last_health_checked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +--- + +## 5. API 设计 + +### 5.1 统一鉴权头 + +LBMS 对外支持: + +```http +Authorization: Bearer +X-API-Key: +``` + +第一阶段可以先支持 LBMS 自己的 `LBMS_API_KEY`。第二阶段支持 LightBridge API Key 透传校验。 + +### 5.2 健康检查 + +```http +GET /mail/v1/health +``` + +返回: + +```json +{ + "success": true, + "data": { + "service": "LightBridge Mail Service", + "status": "ok", + "driver_status": "ok", + "version": "0.1.0" + } +} +``` + +### 5.3 邮箱列表 + +```http +GET /mail/v1/mailboxes?keyword=aa@qq.com&status=active&page=1&page_size=20 +``` + +### 5.4 创建或关联邮箱 + +```http +POST /mail/v1/mailboxes/link-or-create +Content-Type: application/json + +{ + "email_address": "aa@qq.com", + "lightbridge_account_id": 101, + "lightbridge_platform": "openai", + "lightbridge_account_type": "oauth" +} +``` + +返回: + +```json +{ + "success": true, + "data": { + "mailbox_id": "mbx_01JABCDEF1234567890", + "lbms_link": "lbms://mailbox/mbx_01JABCDEF1234567890", + "email_address": "aa@qq.com", + "binding_id": "bind_01J..." + } +} +``` + +### 5.5 OAuth 账户获取验证码 + +```http +GET /mail/v1/accounts/{account_id}/verification-code?since_minutes=10&code_length=6 +Authorization: Bearer +``` + +返回: + +```json +{ + "success": true, + "data": { + "mailbox_id": "mbx_01JABCDEF1234567890", + "email_address": "aa@qq.com", + "code": "123456", + "received_at": "2026-06-12T10:20:30Z", + "confidence": "high" + } +} +``` + +### 5.6 邮箱获取验证码 + +```http +GET /mail/v1/mailboxes/{mailbox_id}/verification-code?since_minutes=10 +``` + +### 5.7 查看邮箱绑定的 OAuth 账户 + +```http +GET /mail/v1/mailboxes/{mailbox_id}/bindings +``` + +返回: + +```json +{ + "success": true, + "data": { + "mailbox_id": "mbx_01JABCDEF1234567890", + "email_address": "aa@qq.com", + "bindings": [ + { + "lightbridge_account_id": 101, + "platform": "openai", + "type": "oauth", + "name": "OpenAI Account A", + "status": "active" + }, + { + "lightbridge_account_id": 102, + "platform": "gemini", + "type": "oauth", + "name": "Gemini Account B", + "status": "active" + } + ] + } +} +``` + +### 5.8 解绑 + +```http +DELETE /mail/v1/accounts/{account_id}/mailbox-link +``` + +解绑时: + +1. 删除或软删除 LBMS binding。 +2. LightBridge account extra 中的 `lbms_link` 由前端或后端适配器清空。 +3. 不删除 mailbox 本身。 +4. 如果 mailbox 没有任何 active binding,则状态变为 `available` 或继续 `active`,由策略决定。 + +--- + +## 6. UI 设计:精确到页面 + +### 6.1 管理后台一级菜单 + +新增菜单: + +```text +管理后台 + └─ LightBridge Mail Service +``` + +菜单显示规则: + +- 管理员可见。 +- 普通用户默认不可见。 +- 如果未来支持用户自管邮箱,则普通用户只看到“我的邮箱服务”。 + +### 6.2 LightBridge Mail Service 首页 + +路径建议: + +```text +/admin/mail-service +``` + +页面布局: + +```text +[标题] LightBridge Mail Service +[副标题] 统一管理 OAuth 账户关联邮箱、邮箱池、验证码和验证链接。 + +[状态卡片区] + - 服务状态:正常 / 异常 / 未配置 + - Driver 状态:正常 / 异常 / 未连接 + - 邮箱总数 + - 已绑定 OAuth 数 + - 最近 24h 验证码读取次数 + - 最近错误数 + +[主要操作] + - 测试连接 + - 新增邮箱 + - 导入邮箱池 + - 查看审计日志 + - 打开设置 +``` + +### 6.3 设置页 + +路径: + +```text +/admin/mail-service/settings +``` + +分区: + +#### A. 基础信息 + +字段: + +| 字段 | UI 类型 | 说明 | +|---|---|---| +| 服务名称 | 只读文本 | LightBridge Mail Service | +| 服务地址 | 输入框 | LBMS sidecar 地址,例如 `http://127.0.0.1:8091` | +| 公开 API 前缀 | 只读 | `/mail/v1` | +| 启用状态 | Switch | 开启/关闭 LBMS 集成 | + +#### B. Driver 配置 + +用户可见文案仍写“邮件服务驱动”,不要写 Outlook。 + +| 字段 | UI 类型 | 说明 | +|---|---|---| +| 驱动类型 | Select | `默认邮件驱动`,高级模式才显示内部值 | +| Driver Base URL | 输入框 | 只在高级模式显示 | +| Driver API Key | 密码框 | 保存后脱敏 | +| 连接超时 | 数字输入 | 默认 10s | +| 请求重试次数 | 数字输入 | 默认 2 | + +按钮: + +```text +[测试连接] +[保存设置] +[重置为默认] +``` + +测试连接结果: + +```text +成功:LightBridge Mail Service 已连接,邮件驱动可用。 +失败:LightBridge Mail Service 暂不可用,请检查服务地址、密钥和网络。 +``` + +#### C. 安全策略 + +字段: + +| 字段 | UI 类型 | 默认值 | +|---|---|---| +| 允许通过 LightBridge API Key 访问 | Switch | 关闭,第二阶段开启 | +| 允许管理员 JWT 访问 | Switch | 开启 | +| 允许普通用户读取自己的绑定邮箱 | Switch | 关闭 | +| 验证码结果缓存秒数 | 数字输入 | 30 | +| 单邮箱每分钟读取限制 | 数字输入 | 10 | +| 单 API Key 每分钟读取限制 | 数字输入 | 60 | +| 邮件内容是否允许显示全文 | Switch | 关闭 | + +#### D. 数据保留 + +字段: + +| 字段 | UI 类型 | 默认值 | +|---|---|---| +| 邮件事件日志保留天数 | 数字输入 | 30 | +| 错误日志保留天数 | 数字输入 | 90 | +| 验证码结果是否落库 | Switch | 关闭 | +| 邮件正文是否落库 | Switch | 关闭 | + +### 6.4 邮箱池页面 + +路径: + +```text +/admin/mail-service/mailboxes +``` + +表格列: + +| 列 | 说明 | +|---|---| +| 邮箱地址 | `aa@qq.com` | +| 状态 | active / available / disabled / error | +| 绑定 OAuth 数 | 支持点击进入绑定列表 | +| 最近邮件时间 | 最近读取到的邮件时间 | +| 最近验证码时间 | 最近成功提取验证码时间 | +| 标签 | 项目、用途、平台 | +| 操作 | 查看 / 绑定 OAuth / 获取验证码 / 禁用 / 删除 | + +顶部筛选: + +```text +[关键词输入框:邮箱地址 / OAuth 名称] +[状态 Select] +[平台 Select:全部 / OpenAI / Gemini / Anthropic / 其他] +[是否已绑定 Select] +[搜索] +[重置] +``` + +批量操作: + +```text +[批量导入] +[批量禁用] +[批量打标签] +[导出邮箱列表] +``` + +### 6.5 邮箱详情页 / 抽屉 + +点击邮箱地址打开右侧抽屉: + +```text +邮箱详情:aa@qq.com + +[基础信息] + 邮箱 ID: mbx_xxx + 状态: active + 创建时间 + 更新时间 + Driver 状态:正常 + +[绑定的 OAuth 账户] + 表格:平台 / 账号名称 / Account ID / 状态 / 操作 + +[最近邮件] + 列表:主题 / 发件人 / 收件时间 / 是否包含验证码 / 操作 + +[验证码] + [获取最新验证码] + [等待新验证码] + [复制验证码] + +[危险操作] + [禁用邮箱] + [解绑所有 OAuth] + [删除邮箱] +``` + +### 6.6 OAuth 账户创建/编辑表单扩展 + +在 OAuth 类型账户表单中增加区块: + +```text +LightBridge Mail Service + +[ ] 关联邮箱服务 + +邮箱地址: [aa@qq.com ] +绑定方式: [查找或创建邮箱 v] +同步策略: [创建 OAuth 账户后建立双向绑定 v] + +[测试读取] [选择已有邮箱] +``` + +绑定方式选项: + +1. 查找或创建邮箱。 +2. 只查找已有邮箱。 +3. 从邮箱池领取一个邮箱。 +4. 暂不绑定,仅保存 OAuth 账户。 + +保存流程: + +1. 用户点击保存 OAuth 账户。 +2. 前端先创建或更新 LightBridge OAuth account。 +3. 前端调用 LBMS `link-or-create`。 +4. LBMS 返回 `lbms_link`。 +5. 前端更新 LightBridge account extra,只写入 `lbms_link`。 +6. LBMS 写入反向 binding。 + +失败处理: + +- 如果 OAuth 账户保存成功、LBMS 绑定失败:页面提示“OAuth 账户已保存,但邮箱绑定失败”,并提供“重试绑定”按钮。 +- 不应回滚 OAuth 账户创建,避免用户丢失凭证。 + +### 6.7 OAuth 账户详情页按钮 + +在 OAuth 账号详情页增加卡片: + +```text +LightBridge Mail Service + +邮箱:aa@qq.com +绑定状态:已绑定 +绑定数量提示:该邮箱还绑定了 3 个 OAuth 账户 + +[获取验证码] +[等待新邮件] +[查看最近邮件] +[复制验证链接] +[更换邮箱] +[解绑] +``` + +如果未绑定: + +```text +LightBridge Mail Service + +当前 OAuth 账户未绑定邮箱。 +[绑定邮箱] +[从邮箱池领取] +``` + +### 6.8 获取验证码交互 + +点击“获取验证码”: + +```text +Modal: 获取验证码 + +邮箱:aa@qq.com +时间范围:[最近 10 分钟 v] +验证码长度:[自动 v] +来源:[邮件标题 + 正文 v] + +[获取] +``` + +成功: + +```text +验证码:123456 +[复制验证码] +[查看来源邮件] +``` + +失败: + +```text +未找到验证码。 +建议: +1. 点击“等待新邮件”持续监听。 +2. 检查邮箱是否收到邮件。 +3. 扩大时间范围。 +``` + +### 6.9 等待新邮件交互 + +点击“等待新邮件”: + +```text +Modal: 等待新邮件 + +等待时间:[60 秒] +匹配内容:[验证码 / 验证链接 / 任意新邮件] + +状态:正在等待新邮件... +进度条:0-60 秒 +[取消] +``` + +成功后自动显示验证码或验证链接。 + +--- + +## 7. 部署计划 + +### 7.1 Docker Compose 可选部署 + +新增文件: + +```text +deploy/docker-compose.mail-service.yml +``` + +示例: + +```yaml +services: + mail-service: + image: weishaw/lightbridge-mail-service:latest + container_name: LightBridge-mail-service + restart: unless-stopped + ports: + - "${LBMS_BIND_HOST:-127.0.0.1}:${LBMS_PORT:-8091}:8091" + environment: + - LBMS_HOST=0.0.0.0 + - LBMS_PORT=8091 + - LBMS_DATABASE_URL=${LBMS_DATABASE_URL:-sqlite:///data/lbms.db} + - LBMS_PUBLIC_NAME=LightBridge Mail Service + - LBMS_DRIVER=outlook_email_plus + - LBMS_DRIVER_BASE_URL=${LBMS_DRIVER_BASE_URL:-http://outlook-mail-plus:5000} + - LBMS_DRIVER_API_KEY=${LBMS_DRIVER_API_KEY:?LBMS_DRIVER_API_KEY is required} + - LBMS_API_KEY=${LBMS_API_KEY:?LBMS_API_KEY is required} + - LIGHTBRIDGE_BASE_URL=${LIGHTBRIDGE_BASE_URL:-http://LightBridge:8080} + volumes: + - ./mail_service_data:/data + depends_on: + - LightBridge +``` + +启动方式: + +```bash +cd deploy +docker compose -f docker-compose.local.yml -f docker-compose.mail-service.yml up -d +``` + +### 7.2 二进制部署 + +新增文件: + +```text +deploy/install-mail-service.sh +deploy/LightBridge-mail-service.service +``` + +安装脚本风格对齐现有 datamanagementd: + +```bash +sudo ./install-mail-service.sh --binary ./lightbridge-mail-service +``` + +systemd 服务: + +```ini +[Unit] +Description=LightBridge Mail Service +After=network.target LightBridge.service +Wants=network.target + +[Service] +Type=simple +User=LightBridge +Group=LightBridge +WorkingDirectory=/opt/LightBridge +EnvironmentFile=-/etc/LightBridge/mail-service.env +ExecStart=/opt/LightBridge/lightbridge-mail-service +Restart=always +RestartSec=5s +LimitNOFILE=100000 +NoNewPrivileges=true +PrivateTmp=true +ReadWritePaths=/var/lib/LightBridge/mail-service + +[Install] +WantedBy=multi-user.target +``` + +--- + +## 8. 稳定性和长期运行策略 + +### 8.1 进程稳定性 + +LBMS 必须具备: + +- systemd `Restart=always`。 +- Docker `restart: unless-stopped`。 +- 健康检查接口。 +- Driver 健康检查。 +- 数据库连接池。 +- 请求超时。 +- 熔断和降级。 + +### 8.2 请求超时建议 + +| 操作 | 默认超时 | 最大超时 | +|---|---:|---:| +| 健康检查 | 3s | 5s | +| 最新邮件 | 10s | 30s | +| 获取验证码 | 10s | 30s | +| 等待新邮件 | 60s | 120s | +| 邮箱池领取 | 10s | 30s | + +### 8.3 重试策略 + +- GET 类读取:最多重试 2 次。 +- POST 绑定类:只在幂等键存在时重试。 +- 邮箱池领取:必须有 idempotency key,避免重复领取。 +- 删除/解绑:使用软删除,失败可重试。 + +### 8.4 幂等键 + +所有写操作支持: + +```http +Idempotency-Key: lbms_{uuid} +``` + +### 8.5 审计日志 + +记录: + +- 谁请求了验证码。 +- 通过哪个 API Key 请求。 +- 请求哪个 mailbox。 +- 是否关联 account_id。 +- 成功/失败。 +- 延迟。 +- 错误码。 + +不记录: + +- 完整邮件正文。 +- 完整验证码结果长期存储。 +- 底层 driver API Key。 + +### 8.6 数据清理任务 + +后台定时任务: + +| 任务 | 频率 | +|---|---| +| 清理过期事件日志 | 每天 03:00 | +| 检查 driver 健康 | 每 1 分钟 | +| 同步邮箱状态 | 每 10 分钟 | +| 清理孤立 binding | 每天 04:00 | + +--- + +## 9. 实施阶段 + +### Phase 0:文档和边界确认 + +交付: + +- 本文档。 +- 明确品牌命名。 +- 明确 Extra 只存 `lbms_link`。 +- 明确邮箱与 OAuth 账户为一对多。 +- 明确 sidecar first,不改内核。 + +验收: + +- 所有对外文案均为 LightBridge Mail Service。 +- 不出现 Outlook Mail Pulse 字段命名。 +- 方案中没有把邮箱细节写入 LightBridge 主体数据库。 + +### Phase 1:LBMS sidecar 最小可用 + +目录建议: + +```text +mailservice/ + cmd/lightbridge-mail-service/main.go + internal/config/ + internal/http/ + internal/store/ + internal/driver/ + internal/driver/outlookemailplus/ + internal/binding/ + internal/audit/ +``` + +实现 API: + +- `GET /mail/v1/health` +- `POST /mail/v1/mailboxes/link-or-create` +- `GET /mail/v1/accounts/{account_id}/verification-code` +- `GET /mail/v1/mailboxes/{mailbox_id}/bindings` +- `DELETE /mail/v1/accounts/{account_id}/mailbox-link` + +验收: + +- 可以启动。 +- 可以连接底层 driver。 +- 可以创建 mailbox。 +- 可以建立 OAuth binding。 +- 可以通过 account_id 获取验证码。 + +### Phase 2:LightBridge 管理 UI + +实现页面: + +- `/admin/mail-service` +- `/admin/mail-service/settings` +- `/admin/mail-service/mailboxes` +- 邮箱详情抽屉。 + +验收: + +- 管理员可以配置服务地址和 API Key。 +- 可以测试连接。 +- 可以看到邮箱池。 +- 可以看到邮箱绑定的 OAuth 账户。 + +### Phase 3:OAuth 账户表单集成 + +实现: + +- OAuth 新增/编辑页中的 LBMS 区块。 +- 保存时自动创建/绑定邮箱。 +- `extra.lbms_link` 写入 LightBridge 账号。 +- 绑定失败的重试按钮。 + +验收: + +- 创建 OpenAI OAuth 时可以绑定邮箱。 +- 创建 Gemini OAuth 时可以绑定同一个邮箱。 +- 一个邮箱可显示多个 OAuth binding。 +- LightBridge account extra 仍只有 `lbms_link`。 + +### Phase 4:统一 API Key 鉴权 + +实现: + +- LBMS 接收 LightBridge API Key。 +- 调用 LightBridge 或只读数据库验证 API Key。 +- 校验用户、分组、account 访问权限。 +- 记录审计日志。 + +验收: + +- 用户可用 LightBridge API Key 请求 `/mail/v1/accounts/{id}/verification-code`。 +- 无权限 account 返回 403。 +- 过期/禁用 API Key 返回 401。 + +### Phase 5:邮箱池自动化 + +实现: + +- 从邮箱池领取邮箱。 +- 创建 OAuth 后 complete。 +- 失败后 release。 +- 支持 project_key。 + +验收: + +- 能从池中领取邮箱。 +- 同一个邮箱可被多个 OAuth 绑定,前提是策略允许。 +- 失败流程不会造成邮箱永久锁死。 + +### Phase 6:生产强化 + +实现: + +- Prometheus metrics。 +- 日志脱敏。 +- 配置热加载。 +- 数据备份文档。 +- 迁移脚本。 +- 灾难恢复文档。 + +验收: + +- 服务重启后绑定关系不丢失。 +- driver 临时不可用时 UI 明确提示,不影响 LightBridge 主服务。 +- 邮件服务停用时 OAuth 账户仍可正常用于网关调度。 + +--- + +## 10. 回滚策略 + +### 10.1 UI 回滚 + +禁用模块或隐藏菜单,不影响主服务。 + +### 10.2 Sidecar 回滚 + +停止服务: + +```bash +sudo systemctl stop LightBridge-mail-service +``` + +Docker: + +```bash +docker compose -f docker-compose.local.yml -f docker-compose.mail-service.yml stop mail-service +``` + +### 10.3 数据回滚 + +由于 LightBridge 主体只写入 `extra.lbms_link`,回滚只需: + +- 保留 extra,不影响账号调度。 +- 或批量清理 `extra.lbms_link`。 + +### 10.4 不可接受的回滚方式 + +禁止为了回滚 LBMS 去删除 OAuth 账户、清空 credentials 或修改调度字段。 + +--- + +## 11. 测试计划 + +### 11.1 单元测试 + +- 邮箱标准化。 +- `lbms://mailbox/{id}` 解析。 +- 一个邮箱多 binding。 +- 一个 account 只能有一个 active binding。 +- driver 错误脱敏。 + +### 11.2 集成测试 + +- LBMS -> driver health。 +- link-or-create。 +- 获取验证码。 +- 等待新邮件。 +- 解绑。 + +### 11.3 UI 测试 + +- 设置页保存。 +- 测试连接。 +- 邮箱池筛选。 +- OAuth 表单绑定邮箱。 +- 同邮箱绑定多个 OAuth 的显示。 +- 获取验证码 modal。 + +### 11.4 稳定性测试 + +- driver 不可用 10 分钟后恢复。 +- LBMS 重启。 +- LightBridge 重启。 +- 数据库断开后恢复。 +- 同时 100 个验证码请求。 + +--- + +## 12. 关键验收清单 + +- [ ] 用户可见品牌只有 LightBridge Mail Service / LBMS。 +- [ ] LightBridge 主体账号 `extra` 只存 `lbms_link`。 +- [ ] LBMS 自己维护 mailbox 和 OAuth binding。 +- [ ] 一个 mailbox 可以绑定多个 OAuth account。 +- [ ] 从 OAuth 账户能查邮箱。 +- [ ] 从邮箱能查所有 OAuth 账户。 +- [ ] 可以在 OAuth 账户详情页获取验证码。 +- [ ] 可以在邮箱详情页查看绑定账户。 +- [ ] LBMS 停止不会影响 LightBridge 主网关调度。 +- [ ] 默认安装 LightBridge 不会安装 LBMS。 +- [ ] Docker 和 systemd 都有可选部署路径。 +- [ ] 所有敏感信息脱敏。 +- [ ] 所有写操作幂等。 +- [ ] 有审计日志和数据清理任务。 + +--- + +## 13. 最终执行原则 + +实现时以“长期稳定执行”为第一优先级: + +1. 不改 LightBridge 内核,先 sidecar。 +2. 不污染主数据库,Extra 只存链接。 +3. 不暴露底层邮件服务品牌,统一 LBMS。 +4. 不把验证码和邮件正文长期写入主库。 +5. 不让邮件服务失败影响 OAuth 账号调度。 +6. 不做不可回滚的强耦合设计。 +7. 每个阶段都必须有独立验收和回滚路径。 diff --git a/examples/modules/lightbridge-mail-service/frontend/remoteEntry.js b/examples/modules/lightbridge-mail-service/frontend/remoteEntry.js new file mode 100644 index 0000000..ccf2bf5 --- /dev/null +++ b/examples/modules/lightbridge-mail-service/frontend/remoteEntry.js @@ -0,0 +1,306 @@ +const style = ` +.lbms { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 20px; color: #111827; } +.lbms h2 { margin: 0 0 8px; font-size: 20px; font-weight: 700; } +.lbms h3 { margin: 20px 0 8px; font-size: 15px; font-weight: 650; } +.lbms p { margin: 0 0 14px; color: #4b5563; font-size: 13px; line-height: 1.55; } +.lbms .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin: 16px 0; } +.lbms .card { border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px; background: #fff; } +.lbms .metric { color: #6b7280; font-size: 12px; } +.lbms .value { margin-top: 6px; font-size: 20px; font-weight: 700; } +.lbms label { display: block; margin: 12px 0 6px; color: #374151; font-size: 12px; font-weight: 600; } +.lbms input, .lbms select { box-sizing: border-box; width: 100%; border: 1px solid #d1d5db; border-radius: 8px; padding: 9px 10px; font-size: 13px; } +.lbms button { border: 1px solid #111827; border-radius: 8px; background: #111827; color: #fff; padding: 8px 12px; font-size: 13px; cursor: pointer; margin: 4px 6px 4px 0; } +.lbms button.secondary { background: #fff; color: #111827; } +.lbms button:disabled { opacity: 0.55; cursor: not-allowed; } +.lbms table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 12px; } +.lbms th, .lbms td { text-align: left; border-bottom: 1px solid #e5e7eb; padding: 10px 8px; } +.lbms th { color: #374151; font-weight: 650; background: #f9fafb; } +.lbms .hint { color: #6b7280; font-size: 12px; } +.lbms .danger { color: #b91c1c; } +.lbms .ok { color: #047857; } +.lbms .pill { display: inline-block; border: 1px solid #d1d5db; border-radius: 999px; padding: 2px 8px; font-size: 12px; background: #f9fafb; } +`; + +function ensureStyle() { + if (document.getElementById("lbms-style")) return; + const node = document.createElement("style"); + node.id = "lbms-style"; + node.textContent = style; + document.head.appendChild(node); +} + +function apiBase() { + return localStorage.getItem("lbms.baseUrl") || "http://127.0.0.1:8091"; +} + +function apiKey() { + return localStorage.getItem("lbms.apiKey") || ""; +} + +async function lbmsFetch(path, options = {}) { + const headers = new Headers(options.headers || {}); + if (apiKey()) headers.set("Authorization", `Bearer ${apiKey()}`); + if (!headers.has("Content-Type") && options.body) headers.set("Content-Type", "application/json"); + const res = await fetch(`${apiBase()}${path}`, { ...options, headers }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error?.message || "LightBridge Mail Service request failed"); + return data; +} + +function mailboxRows(payload) { + return payload?.data?.mailboxes || []; +} + +function formatTime(value) { + if (!value) return "—"; + try { + return new Date(value).toLocaleString(); + } catch { + return value; + } +} + +const MailServiceHome = { + name: "MailServiceHome", + data() { + return { + health: null, + mailboxes: [], + loading: false, + error: "", + }; + }, + mounted() { + ensureStyle(); + this.refresh(); + }, + computed: { + bindingTotal() { + return this.mailboxes.reduce((sum, item) => sum + Number(item.binding_count || 0), 0); + }, + }, + methods: { + async refresh() { + this.loading = true; + this.error = ""; + try { + const [health, mailboxPayload] = await Promise.all([ + lbmsFetch("/mail/v1/health"), + lbmsFetch("/mail/v1/mailboxes"), + ]); + this.health = health; + this.mailboxes = mailboxRows(mailboxPayload); + } catch (err) { + this.health = null; + this.mailboxes = []; + this.error = err.message || "LightBridge Mail Service 暂不可用。"; + } finally { + this.loading = false; + } + }, + }, + template: ` +
+

LightBridge Mail Service

+

统一管理 OAuth 账户关联邮箱、邮箱池、验证码和验证链接。

+
+
服务状态
{{ health ? '正常' : '未连接' }}
+
Driver 状态
{{ health?.data?.driver_status || '未知' }}
+
邮箱总数
{{ mailboxes.length }}
+
已绑定 OAuth
{{ bindingTotal }}
+
+

{{ error }}

+ + + + + +
+ `, +}; + +const MailServiceSettings = { + name: "MailServiceSettings", + data() { + return { + baseUrl: apiBase(), + key: apiKey(), + enabled: true, + status: "", + }; + }, + mounted() { ensureStyle(); }, + methods: { + save() { + localStorage.setItem("lbms.baseUrl", this.baseUrl.trim()); + localStorage.setItem("lbms.apiKey", this.key.trim()); + this.status = "设置已保存到当前浏览器。后续应接入 LightBridge 模块 secret 存储。"; + }, + async test() { + this.save(); + try { + const health = await lbmsFetch("/mail/v1/health"); + this.status = `LightBridge Mail Service 已连接,Driver 状态:${health?.data?.driver_status || '未知'},Store:${health?.data?.store?.type || '未知'}`; + } catch (err) { + this.status = err.message || "LightBridge Mail Service 暂不可用。"; + } + }, + }, + template: ` +
+

LightBridge Mail Service 设置

+

配置 sidecar 地址和访问密钥。底层邮件驱动只在服务端配置,不应暴露给普通用户。

+

基础信息

+ + + + +

安全策略

+ +

Phase 1 使用 LBMS API Key。后续 Phase 4 会接入 LightBridge API Key 校验。

+ + +

{{ status }}

+
+ `, +}; + +const MailServiceMailboxes = { + name: "MailServiceMailboxes", + data() { + return { + keyword: "", + status: "all", + rows: [], + loading: false, + error: "", + }; + }, + mounted() { + ensureStyle(); + this.load(); + }, + computed: { + filteredRows() { + const keyword = this.keyword.trim().toLowerCase(); + return this.rows.filter((row) => { + const statusMatched = this.status === "all" || row.status === this.status; + const keywordMatched = !keyword || String(row.email_address || "").toLowerCase().includes(keyword) || String(row.id || "").toLowerCase().includes(keyword); + return statusMatched && keywordMatched; + }); + }, + }, + methods: { + formatTime, + async load() { + this.loading = true; + this.error = ""; + try { + const payload = await lbmsFetch("/mail/v1/mailboxes"); + this.rows = mailboxRows(payload); + } catch (err) { + this.rows = []; + this.error = err.message || "无法读取邮箱池。"; + } finally { + this.loading = false; + } + }, + reset() { + this.keyword = ""; + this.status = "all"; + this.load(); + }, + }, + template: ` +
+

邮箱池

+

查看邮箱、绑定 OAuth 数量和操作入口。邮箱详情保存在 LightBridge Mail Service;LightBridge 主账号 Extra 只保留 lbms_link。

+
+
+
+
+
+ +

{{ error }}

+ + + + + + + + + + + + + + +
邮箱地址状态绑定 OAuth 数创建时间更新时间Mailbox ID操作
暂无邮箱,或当前筛选条件没有结果。
{{ row.email_address }}{{ row.status }}{{ row.binding_count }}{{ formatTime(row.created_at) }}{{ formatTime(row.updated_at) }}{{ row.id }}
+
+ `, +}; + +const OAuthMailServicePanel = { + name: "OAuthMailServicePanel", + props: { + modelValue: { type: Object, default: () => ({}) }, + }, + emits: ["update:modelValue"], + data() { + return { + enabled: Boolean(this.modelValue?.extra?.lbms_link), + emailAddress: "", + bindMode: "link_or_create", + syncPolicy: "create_binding_after_account_save", + status: "", + }; + }, + mounted() { ensureStyle(); }, + methods: { + updateExtra(key, value) { + const current = this.modelValue || {}; + this.$emit("update:modelValue", { + ...current, + extra: { + ...(current.extra || {}), + [key]: value, + }, + }); + }, + setLink(link) { + this.updateExtra("lbms_link", link); + }, + markPending() { + this.status = "保存 OAuth 账户后,将由 LightBridge Mail Service 建立双向绑定,并只把 lbms_link 写入账号 Extra。"; + }, + }, + template: ` +
+

LightBridge Mail Service

+

为当前 OAuth 账户关联邮箱。一个邮箱可以绑定多个 OAuth 账户;当前 OAuth 账户只保存一个 lbms_link。

+ + +

{{ status }}

+
+ `, +}; + +export { MailServiceHome, MailServiceSettings, MailServiceMailboxes, OAuthMailServicePanel }; diff --git a/examples/modules/lightbridge-mail-service/module.template.yaml b/examples/modules/lightbridge-mail-service/module.template.yaml new file mode 100644 index 0000000..8cde66f --- /dev/null +++ b/examples/modules/lightbridge-mail-service/module.template.yaml @@ -0,0 +1,47 @@ +apiVersion: lightbridge/v1alpha1 +id: lightbridge-mail-service +name: LightBridge Mail Service +type: ui +version: 0.1.0 +core: + compatible: ">=0.1.0 <0.2.0" +frontend: + entry: frontend/remoteEntry.js + routes: + - path: /admin/mail-service + title: LightBridge Mail Service + exposedModule: ./MailServiceHome + requiresAdmin: true + - path: /admin/mail-service/settings + title: LightBridge Mail Service Settings + exposedModule: ./MailServiceSettings + requiresAdmin: true + - path: /admin/mail-service/mailboxes + title: LightBridge Mail Service Mailboxes + exposedModule: ./MailServiceMailboxes + requiresAdmin: true + menu: + - title: LightBridge Mail Service + path: /admin/mail-service + group: Integrations + order: 30 + accountForms: + - providerId: openai + exposedModule: ./OAuthMailServicePanel + - providerId: gemini + exposedModule: ./OAuthMailServicePanel + - providerId: anthropic + exposedModule: ./OAuthMailServicePanel +capabilities: + - ui.admin.route + - ui.account.form +permissions: + network: + - http://127.0.0.1:8091/mail/v1/* + - http://mail-service:8091/mail/v1/* + secrets: + - lbms_api_key +notes: + - User visible copy must only use LightBridge Mail Service or LBMS. + - LightBridge account extra should only persist lbms_link. + - The underlying mail driver is an internal implementation detail. diff --git a/mailservice/Dockerfile b/mailservice/Dockerfile new file mode 100644 index 0000000..0560fa1 --- /dev/null +++ b/mailservice/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.26-alpine AS builder + +WORKDIR /src +COPY go.mod ./ +COPY main.go ./ +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/lightbridge-mail-service . + +FROM alpine:3.21 +WORKDIR /app +COPY --from=builder /out/lightbridge-mail-service /app/lightbridge-mail-service +VOLUME ["/data"] +EXPOSE 8091 +ENTRYPOINT ["/app/lightbridge-mail-service"] diff --git a/mailservice/README.md b/mailservice/README.md new file mode 100644 index 0000000..090e551 --- /dev/null +++ b/mailservice/README.md @@ -0,0 +1,130 @@ +# LightBridge Mail Service + +LightBridge Mail Service (LBMS) is an optional sidecar for LightBridge. It manages mailbox entities, OAuth account bindings, verification-code retrieval, and driver integration without modifying the LightBridge core service. + +## Current scope + +This implementation is still a Phase 1 scaffold, but it now has durable local state: + +- Exposes `/mail/v1/*` APIs. +- Uses the external name **LightBridge Mail Service** only. +- Keeps LightBridge account `extra` minimal: `lbms_link` should be the only persisted reference in the main LightBridge database. +- Maintains a bidirectional mailbox-to-OAuth binding model inside the sidecar. +- Allows one mailbox to bind multiple OAuth accounts. +- Allows one OAuth account to have only one active mailbox binding. +- Persists mailbox and OAuth binding metadata to a LBMS-owned JSON store through `LBMS_DATA_PATH`. +- Uses a short-lived in-memory verification-code cache only for repeated reads. + +The JSON store is intended as the first durable step for single-node deployments. A future production phase can replace it with SQLite or PostgreSQL without changing LightBridge account `extra`, because the main database only stores `lbms_link`. + +## API + +### Health + +```bash +curl http://127.0.0.1:8091/mail/v1/health +``` + +The health response includes the service name, driver status, version, and configured store path. + +### List mailboxes + +```bash +curl http://127.0.0.1:8091/mail/v1/mailboxes \ + -H "Authorization: Bearer $LBMS_API_KEY" +``` + +This endpoint powers the mailbox pool UI. It returns mailbox summaries with binding counts and timestamps. + +### Link or create mailbox + +```bash +curl -X POST http://127.0.0.1:8091/mail/v1/mailboxes/link-or-create \ + -H "Authorization: Bearer $LBMS_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "email_address": "aa@qq.com", + "lightbridge_account_id": 101, + "lightbridge_platform": "openai", + "lightbridge_account_type": "oauth", + "lightbridge_account_name": "OpenAI OAuth A" + }' +``` + +Response contains the only value that should be written back to LightBridge account `extra`: + +```json +{ + "lbms_link": "lbms://mailbox/mbx_xxx" +} +``` + +### Get mailbox link by OAuth account + +```bash +curl http://127.0.0.1:8091/mail/v1/accounts/101/mailbox-link \ + -H "Authorization: Bearer $LBMS_API_KEY" +``` + +### Get verification code by OAuth account + +```bash +curl "http://127.0.0.1:8091/mail/v1/accounts/101/verification-code?since_minutes=10&code_length=6" \ + -H "Authorization: Bearer $LBMS_API_KEY" +``` + +### List OAuth bindings for a mailbox + +```bash +curl http://127.0.0.1:8091/mail/v1/mailboxes/mbx_xxx/bindings \ + -H "Authorization: Bearer $LBMS_API_KEY" +``` + +### Unlink account + +```bash +curl -X DELETE http://127.0.0.1:8091/mail/v1/accounts/101/mailbox-link \ + -H "Authorization: Bearer $LBMS_API_KEY" +``` + +## Configuration + +| Variable | Default | Description | +|---|---:|---| +| `LBMS_HOST` | `0.0.0.0` | Bind host. | +| `LBMS_PORT` | `8091` | Bind port. | +| `LBMS_API_KEY` | empty | Required for all non-health APIs. | +| `LBMS_DATA_PATH` | `data/lbms-store.json` | LBMS-owned persistent JSON store path. | +| `LBMS_DRIVER` | `outlook_email_plus` | Internal driver identifier. Do not expose this in UI. | +| `LBMS_DRIVER_BASE_URL` | empty | Internal driver base URL. | +| `LBMS_DRIVER_API_KEY` | empty | Internal driver API key. | +| `LBMS_REQUEST_TIMEOUT_SECONDS` | `10` | Outbound driver timeout. | +| `LBMS_VERIFICATION_CACHE_SECONDS` | `30` | Short-lived verification result cache. | + +## Run locally + +```bash +cd mailservice +export LBMS_API_KEY=dev-lbms-token +export LBMS_DATA_PATH=/tmp/lbms-store.json +export LBMS_DRIVER_BASE_URL=http://127.0.0.1:5000 +export LBMS_DRIVER_API_KEY=driver-token +go run . +``` + +## Store behavior + +The sidecar writes mailbox and binding metadata to `LBMS_DATA_PATH` after each link or unlink operation. Writes use a temporary file, fsync, and atomic rename so a process crash is less likely to leave a partially written store file. + +The verification-code cache is intentionally not persisted. Codes are short lived and should be fetched from the configured mail driver again after restart. + +## Production TODO + +Before production rollout, continue with: + +1. SQLite or PostgreSQL store adapter for higher write concurrency. +2. Migration files for `lbms_mailboxes`, `lbms_oauth_bindings`, `lbms_mail_events`, and `lbms_driver_accounts`. +3. Audit logging and retention jobs. +4. LightBridge API Key verification adapter. +5. Request rate limits and idempotency-key storage. +6. UI wiring that writes `extra.lbms_link` after OAuth account save succeeds. diff --git a/mailservice/go.mod b/mailservice/go.mod new file mode 100644 index 0000000..9a5701d --- /dev/null +++ b/mailservice/go.mod @@ -0,0 +1,3 @@ +module github.com/Wei-Shaw/LightBridge/mailservice + +go 1.26.3 diff --git a/mailservice/main.go b/mailservice/main.go new file mode 100644 index 0000000..5856e54 --- /dev/null +++ b/mailservice/main.go @@ -0,0 +1,834 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +const ( + serviceName = "LightBridge Mail Service" + version = "0.1.0" +) + +type Config struct { + Host string + Port string + APIKey string + Driver string + DriverBaseURL string + DriverAPIKey string + DataPath string + RequestTimeout time.Duration + VerificationCacheTTL time.Duration +} + +func LoadConfig() Config { + return Config{ + Host: envOrDefault("LBMS_HOST", "0.0.0.0"), + Port: envOrDefault("LBMS_PORT", "8091"), + APIKey: strings.TrimSpace(os.Getenv("LBMS_API_KEY")), + Driver: envOrDefault("LBMS_DRIVER", "outlook_email_plus"), + DriverBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("LBMS_DRIVER_BASE_URL")), "/"), + DriverAPIKey: strings.TrimSpace(os.Getenv("LBMS_DRIVER_API_KEY")), + DataPath: envOrDefault("LBMS_DATA_PATH", "data/lbms-store.json"), + RequestTimeout: envDurationSeconds("LBMS_REQUEST_TIMEOUT_SECONDS", 10), + VerificationCacheTTL: envDurationSeconds("LBMS_VERIFICATION_CACHE_SECONDS", 30), + } +} + +func envOrDefault(key, fallback string) string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + return value +} + +func envDurationSeconds(key string, fallback int) time.Duration { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return time.Duration(fallback) * time.Second + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 { + return time.Duration(fallback) * time.Second + } + return time.Duration(parsed) * time.Second +} + +type Mailbox struct { + ID string `json:"id"` + EmailAddress string `json:"email_address"` + NormalizedEmail string `json:"normalized_email"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type OAuthBinding struct { + ID string `json:"id"` + MailboxID string `json:"mailbox_id"` + LightBridgeAccountID int64 `json:"lightbridge_account_id"` + LightBridgePlatform string `json:"lightbridge_platform"` + LightBridgeAccountType string `json:"lightbridge_account_type"` + LightBridgeAccountName string `json:"lightbridge_account_name,omitempty"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Store struct { + mu sync.RWMutex + dataPath string + mailboxesByID map[string]*Mailbox + mailboxIDByEmail map[string]string + bindingsByAccount map[int64]*OAuthBinding + bindingsByMailbox map[string]map[int64]*OAuthBinding + verificationCache map[string]cachedVerification +} + +type persistedStore struct { + Version int `json:"version"` + SavedAt time.Time `json:"saved_at"` + Mailboxes []*Mailbox `json:"mailboxes"` + Bindings []*OAuthBinding `json:"bindings"` +} + +type cachedVerification struct { + Code string + ReceivedAt string + ExpiresAt time.Time +} + +func NewStore(dataPath string) (*Store, error) { + s := &Store{ + dataPath: strings.TrimSpace(dataPath), + mailboxesByID: map[string]*Mailbox{}, + mailboxIDByEmail: map[string]string{}, + bindingsByAccount: map[int64]*OAuthBinding{}, + bindingsByMailbox: map[string]map[int64]*OAuthBinding{}, + verificationCache: map[string]cachedVerification{}, + } + if err := s.load(); err != nil { + return nil, err + } + return s, nil +} + +func (s *Store) load() error { + if s.dataPath == "" { + return nil + } + file, err := os.Open(s.dataPath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("open_store: %w", err) + } + defer file.Close() + + var snapshot persistedStore + if err := json.NewDecoder(file).Decode(&snapshot); err != nil { + return fmt.Errorf("decode_store: %w", err) + } + for _, mailbox := range snapshot.Mailboxes { + if mailbox == nil || mailbox.ID == "" || mailbox.NormalizedEmail == "" { + continue + } + copy := *mailbox + s.mailboxesByID[copy.ID] = © + s.mailboxIDByEmail[copy.NormalizedEmail] = copy.ID + } + for _, binding := range snapshot.Bindings { + if binding == nil || binding.LightBridgeAccountID <= 0 || binding.MailboxID == "" { + continue + } + if _, ok := s.mailboxesByID[binding.MailboxID]; !ok { + continue + } + copy := *binding + s.bindingsByAccount[copy.LightBridgeAccountID] = © + if s.bindingsByMailbox[copy.MailboxID] == nil { + s.bindingsByMailbox[copy.MailboxID] = map[int64]*OAuthBinding{} + } + s.bindingsByMailbox[copy.MailboxID][copy.LightBridgeAccountID] = © + } + return nil +} + +func (s *Store) saveLocked() error { + if s.dataPath == "" { + return nil + } + mailboxes := make([]*Mailbox, 0, len(s.mailboxesByID)) + for _, mailbox := range s.mailboxesByID { + copy := *mailbox + mailboxes = append(mailboxes, ©) + } + sort.Slice(mailboxes, func(i, j int) bool { return mailboxes[i].ID < mailboxes[j].ID }) + + bindings := make([]*OAuthBinding, 0, len(s.bindingsByAccount)) + for _, binding := range s.bindingsByAccount { + copy := *binding + bindings = append(bindings, ©) + } + sort.Slice(bindings, func(i, j int) bool { return bindings[i].ID < bindings[j].ID }) + + snapshot := persistedStore{ + Version: 1, + SavedAt: time.Now().UTC(), + Mailboxes: mailboxes, + Bindings: bindings, + } + + if err := os.MkdirAll(filepath.Dir(s.dataPath), 0o700); err != nil { + return fmt.Errorf("create_store_dir: %w", err) + } + tmp := s.dataPath + ".tmp" + file, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("open_store_tmp: %w", err) + } + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(snapshot); err != nil { + _ = file.Close() + return fmt.Errorf("encode_store: %w", err) + } + if err := file.Sync(); err != nil { + _ = file.Close() + return fmt.Errorf("sync_store: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close_store: %w", err) + } + if err := os.Rename(tmp, s.dataPath); err != nil { + return fmt.Errorf("replace_store: %w", err) + } + return nil +} + +func (s *Store) LinkOrCreate(req LinkOrCreateRequest) (*Mailbox, *OAuthBinding, error) { + normalized := normalizeEmail(req.EmailAddress) + if normalized == "" || !strings.Contains(normalized, "@") { + return nil, nil, errors.New("invalid_email_address") + } + if req.LightBridgeAccountID <= 0 { + return nil, nil, errors.New("invalid_lightbridge_account_id") + } + if strings.TrimSpace(req.LightBridgePlatform) == "" { + return nil, nil, errors.New("invalid_lightbridge_platform") + } + if strings.TrimSpace(req.LightBridgeAccountType) == "" { + req.LightBridgeAccountType = "oauth" + } + + now := time.Now().UTC() + s.mu.Lock() + defer s.mu.Unlock() + + mailboxID, ok := s.mailboxIDByEmail[normalized] + var mailbox *Mailbox + if ok { + mailbox = s.mailboxesByID[mailboxID] + mailbox.UpdatedAt = now + } else { + mailbox = &Mailbox{ + ID: newID("mbx"), + EmailAddress: strings.TrimSpace(req.EmailAddress), + NormalizedEmail: normalized, + Status: "active", + CreatedAt: now, + UpdatedAt: now, + } + s.mailboxesByID[mailbox.ID] = mailbox + s.mailboxIDByEmail[normalized] = mailbox.ID + } + + if existing := s.bindingsByAccount[req.LightBridgeAccountID]; existing != nil { + if existing.MailboxID != mailbox.ID { + delete(s.bindingsByMailbox[existing.MailboxID], req.LightBridgeAccountID) + } + existing.MailboxID = mailbox.ID + existing.LightBridgePlatform = strings.TrimSpace(req.LightBridgePlatform) + existing.LightBridgeAccountType = strings.TrimSpace(req.LightBridgeAccountType) + existing.LightBridgeAccountName = strings.TrimSpace(req.LightBridgeAccountName) + existing.Status = "active" + existing.UpdatedAt = now + if s.bindingsByMailbox[mailbox.ID] == nil { + s.bindingsByMailbox[mailbox.ID] = map[int64]*OAuthBinding{} + } + s.bindingsByMailbox[mailbox.ID][req.LightBridgeAccountID] = existing + if err := s.saveLocked(); err != nil { + return nil, nil, err + } + return cloneMailbox(mailbox), cloneBinding(existing), nil + } + + binding := &OAuthBinding{ + ID: newID("bind"), + MailboxID: mailbox.ID, + LightBridgeAccountID: req.LightBridgeAccountID, + LightBridgePlatform: strings.TrimSpace(req.LightBridgePlatform), + LightBridgeAccountType: strings.TrimSpace(req.LightBridgeAccountType), + LightBridgeAccountName: strings.TrimSpace(req.LightBridgeAccountName), + Status: "active", + CreatedAt: now, + UpdatedAt: now, + } + s.bindingsByAccount[req.LightBridgeAccountID] = binding + if s.bindingsByMailbox[mailbox.ID] == nil { + s.bindingsByMailbox[mailbox.ID] = map[int64]*OAuthBinding{} + } + s.bindingsByMailbox[mailbox.ID][req.LightBridgeAccountID] = binding + if err := s.saveLocked(); err != nil { + return nil, nil, err + } + return cloneMailbox(mailbox), cloneBinding(binding), nil +} + +func (s *Store) ListMailboxes() []*MailboxSummary { + s.mu.RLock() + defer s.mu.RUnlock() + items := make([]*MailboxSummary, 0, len(s.mailboxesByID)) + for _, mailbox := range s.mailboxesByID { + bindingCount := 0 + for _, binding := range s.bindingsByMailbox[mailbox.ID] { + if binding.Status == "active" { + bindingCount++ + } + } + items = append(items, &MailboxSummary{ + ID: mailbox.ID, + EmailAddress: mailbox.EmailAddress, + Status: mailbox.Status, + BindingCount: bindingCount, + CreatedAt: mailbox.CreatedAt, + UpdatedAt: mailbox.UpdatedAt, + }) + } + sort.Slice(items, func(i, j int) bool { return items[i].UpdatedAt.After(items[j].UpdatedAt) }) + return items +} + +func (s *Store) BindingByAccount(accountID int64) (*OAuthBinding, *Mailbox, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + binding := s.bindingsByAccount[accountID] + if binding == nil || binding.Status != "active" { + return nil, nil, false + } + mailbox := s.mailboxesByID[binding.MailboxID] + if mailbox == nil { + return nil, nil, false + } + return cloneBinding(binding), cloneMailbox(mailbox), true +} + +func (s *Store) BindingsByMailbox(mailboxID string) (*Mailbox, []*OAuthBinding, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + mailbox := s.mailboxesByID[mailboxID] + if mailbox == nil { + return nil, nil, false + } + bindings := make([]*OAuthBinding, 0, len(s.bindingsByMailbox[mailboxID])) + for _, binding := range s.bindingsByMailbox[mailboxID] { + if binding.Status == "active" { + bindings = append(bindings, cloneBinding(binding)) + } + } + sort.Slice(bindings, func(i, j int) bool { return bindings[i].LightBridgeAccountID < bindings[j].LightBridgeAccountID }) + return cloneMailbox(mailbox), bindings, true +} + +func (s *Store) UnlinkAccount(accountID int64) bool { + s.mu.Lock() + defer s.mu.Unlock() + binding := s.bindingsByAccount[accountID] + if binding == nil { + return false + } + delete(s.bindingsByAccount, accountID) + delete(s.bindingsByMailbox[binding.MailboxID], accountID) + if err := s.saveLocked(); err != nil { + log.Printf("%s failed to persist unlink: %v", serviceName, err) + } + return true +} + +func (s *Store) GetCachedVerification(key string) (cachedVerification, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + cached, ok := s.verificationCache[key] + if !ok || time.Now().UTC().After(cached.ExpiresAt) { + return cachedVerification{}, false + } + return cached, true +} + +func (s *Store) SetCachedVerification(key, code, receivedAt string, ttl time.Duration) { + if ttl <= 0 || code == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.verificationCache[key] = cachedVerification{ + Code: code, + ReceivedAt: receivedAt, + ExpiresAt: time.Now().UTC().Add(ttl), + } +} + +func cloneMailbox(mailbox *Mailbox) *Mailbox { + if mailbox == nil { + return nil + } + copy := *mailbox + return © +} + +func cloneBinding(binding *OAuthBinding) *OAuthBinding { + if binding == nil { + return nil + } + copy := *binding + return © +} + +type LinkOrCreateRequest struct { + EmailAddress string `json:"email_address"` + LightBridgeAccountID int64 `json:"lightbridge_account_id"` + LightBridgePlatform string `json:"lightbridge_platform"` + LightBridgeAccountType string `json:"lightbridge_account_type"` + LightBridgeAccountName string `json:"lightbridge_account_name"` +} + +type MailboxSummary struct { + ID string `json:"id"` + EmailAddress string `json:"email_address"` + Status string `json:"status"` + BindingCount int `json:"binding_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DriverClient struct { + baseURL string + apiKey string + client *http.Client +} + +func NewDriverClient(cfg Config) *DriverClient { + return &DriverClient{ + baseURL: cfg.DriverBaseURL, + apiKey: cfg.DriverAPIKey, + client: &http.Client{ + Timeout: cfg.RequestTimeout, + }, + } +} + +func (d *DriverClient) Configured() bool { + return d.baseURL != "" && d.apiKey != "" +} + +func (d *DriverClient) Health(ctx context.Context) string { + if !d.Configured() { + return "not_configured" + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.baseURL+"/api/external/health", nil) + if err != nil { + return "error" + } + req.Header.Set("X-API-Key", d.apiKey) + resp, err := d.client.Do(req) + if err != nil { + return "error" + } + defer resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return "ok" + } + return "error" +} + +func (d *DriverClient) VerificationCode(ctx context.Context, email string, sinceMinutes, codeLength int) (map[string]any, error) { + if !d.Configured() { + return nil, errors.New("mail_driver_not_configured") + } + endpoint, err := url.Parse(d.baseURL + "/api/external/verification-code") + if err != nil { + return nil, err + } + query := endpoint.Query() + query.Set("email", email) + if sinceMinutes > 0 { + query.Set("since_minutes", strconv.Itoa(sinceMinutes)) + } + if codeLength > 0 { + query.Set("code_length", strconv.Itoa(codeLength)) + } + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("X-API-Key", d.apiKey) + resp, err := d.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var payload map[string]any + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("mail_driver_http_%d", resp.StatusCode) + } + return payload, nil +} + +type Server struct { + cfg Config + store *Store + driver *DriverClient +} + +func main() { + cfg := LoadConfig() + store, err := NewStore(cfg.DataPath) + if err != nil { + log.Fatalf("%s failed to load store: %v", serviceName, err) + } + server := &Server{ + cfg: cfg, + store: store, + driver: NewDriverClient(cfg), + } + + mux := http.NewServeMux() + mux.HandleFunc("/mail/v1/health", server.handleHealth) + mux.HandleFunc("/mail/v1/mailboxes", server.withAuth(server.handleMailboxes)) + mux.HandleFunc("/mail/v1/mailboxes/link-or-create", server.withAuth(server.handleLinkOrCreate)) + mux.HandleFunc("/mail/v1/accounts/", server.withAuth(server.handleAccountRoute)) + mux.HandleFunc("/mail/v1/mailboxes/", server.withAuth(server.handleMailboxRoute)) + + addr := cfg.Host + ":" + cfg.Port + log.Printf("%s starting on %s", serviceName, addr) + log.Printf("%s store path: %s", serviceName, cfg.DataPath) + if err := http.ListenAndServe(addr, requestIDMiddleware(mux)); err != nil { + log.Fatal(err) + } +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + writeJSON(w, http.StatusOK, map[string]any{ + "success": true, + "data": map[string]any{ + "service": serviceName, + "status": "ok", + "driver_status": s.driver.Health(ctx), + "store": map[string]any{ + "type": "json_file", + "path": s.cfg.DataPath, + }, + "version": version, + }, + }) +} + +func (s *Server) handleMailboxes(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "success": true, + "data": map[string]any{ + "mailboxes": s.store.ListMailboxes(), + }, + }) +} + +func (s *Server) handleLinkOrCreate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + var req LinkOrCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", "invalid request body") + return + } + mailbox, binding, err := s.store.LinkOrCreate(req) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error(), "unable to link mailbox") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "success": true, + "data": map[string]any{ + "mailbox_id": mailbox.ID, + "lbms_link": "lbms://mailbox/" + mailbox.ID, + "email_address": mailbox.EmailAddress, + "binding_id": binding.ID, + }, + }) +} + +func (s *Server) handleAccountRoute(w http.ResponseWriter, r *http.Request) { + trimmed := strings.TrimPrefix(r.URL.Path, "/mail/v1/accounts/") + parts := strings.Split(strings.Trim(trimmed, "/"), "/") + if len(parts) != 2 { + writeError(w, http.StatusNotFound, "not_found", "route not found") + return + } + accountID, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil || accountID <= 0 { + writeError(w, http.StatusBadRequest, "invalid_account_id", "invalid account id") + return + } + + switch parts[1] { + case "verification-code": + s.handleAccountVerificationCode(w, r, accountID) + case "mailbox-link": + s.handleAccountMailboxLink(w, r, accountID) + default: + writeError(w, http.StatusNotFound, "not_found", "route not found") + } +} + +func (s *Server) handleAccountVerificationCode(w http.ResponseWriter, r *http.Request, accountID int64) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + _, mailbox, ok := s.store.BindingByAccount(accountID) + if !ok { + writeError(w, http.StatusNotFound, "mailbox_binding_not_found", "mailbox binding not found") + return + } + sinceMinutes := intQuery(r, "since_minutes", 10) + codeLength := intQuery(r, "code_length", 0) + cacheKey := fmt.Sprintf("account:%d:%d:%d", accountID, sinceMinutes, codeLength) + if cached, ok := s.store.GetCachedVerification(cacheKey); ok { + writeVerificationCode(w, mailbox, cached.Code, cached.ReceivedAt, true) + return + } + + payload, err := s.driver.VerificationCode(r.Context(), mailbox.EmailAddress, sinceMinutes, codeLength) + if err != nil { + writeError(w, http.StatusBadGateway, "mail_service_driver_error", "LightBridge Mail Service cannot fetch verification code") + return + } + code, receivedAt := extractCode(payload) + if code != "" { + s.store.SetCachedVerification(cacheKey, code, receivedAt, s.cfg.VerificationCacheTTL) + } + writeVerificationCode(w, mailbox, code, receivedAt, false) +} + +func (s *Server) handleAccountMailboxLink(w http.ResponseWriter, r *http.Request, accountID int64) { + switch r.Method { + case http.MethodGet: + binding, mailbox, ok := s.store.BindingByAccount(accountID) + if !ok { + writeError(w, http.StatusNotFound, "mailbox_binding_not_found", "mailbox binding not found") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "success": true, + "data": map[string]any{ + "lbms_link": "lbms://mailbox/" + mailbox.ID, + "mailbox_id": mailbox.ID, + "email_address": mailbox.EmailAddress, + "binding": binding, + }, + }) + case http.MethodDelete: + if !s.store.UnlinkAccount(accountID) { + writeError(w, http.StatusNotFound, "mailbox_binding_not_found", "mailbox binding not found") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "success": true, + "data": map[string]any{ + "lightbridge_account_id": accountID, + "unlinked": true, + }, + }) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +func (s *Server) handleMailboxRoute(w http.ResponseWriter, r *http.Request) { + trimmed := strings.TrimPrefix(r.URL.Path, "/mail/v1/mailboxes/") + parts := strings.Split(strings.Trim(trimmed, "/"), "/") + if len(parts) != 2 || parts[1] != "bindings" { + writeError(w, http.StatusNotFound, "not_found", "route not found") + return + } + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + mailbox, bindings, ok := s.store.BindingsByMailbox(parts[0]) + if !ok { + writeError(w, http.StatusNotFound, "mailbox_not_found", "mailbox not found") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "success": true, + "data": map[string]any{ + "mailbox_id": mailbox.ID, + "email_address": mailbox.EmailAddress, + "bindings": bindings, + }, + }) +} + +func (s *Server) withAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if s.cfg.APIKey == "" { + writeError(w, http.StatusServiceUnavailable, "lbms_api_key_not_configured", "LightBridge Mail Service API key is not configured") + return + } + token := bearerToken(r.Header.Get("Authorization")) + if token == "" { + token = strings.TrimSpace(r.Header.Get("X-API-Key")) + } + if subtle.ConstantTimeCompare([]byte(token), []byte(s.cfg.APIKey)) != 1 { + writeError(w, http.StatusUnauthorized, "unauthorized", "invalid LightBridge Mail Service API key") + return + } + next(w, r) + } +} + +func bearerToken(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + parts := strings.SplitN(value, " ", 2) + if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { + return strings.TrimSpace(parts[1]) + } + return "" +} + +func requestIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := strings.TrimSpace(r.Header.Get("X-Request-ID")) + if requestID == "" { + requestID = newID("req") + } + w.Header().Set("X-Request-ID", requestID) + next.ServeHTTP(w, r) + }) +} + +func writeVerificationCode(w http.ResponseWriter, mailbox *Mailbox, code, receivedAt string, cached bool) { + writeJSON(w, http.StatusOK, map[string]any{ + "success": true, + "data": map[string]any{ + "mailbox_id": mailbox.ID, + "email_address": mailbox.EmailAddress, + "code": code, + "received_at": receivedAt, + "confidence": "high", + "cached": cached, + }, + }) +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func writeError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, map[string]any{ + "success": false, + "error": map[string]any{ + "code": code, + "message": message, + }, + }) +} + +func normalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +func intQuery(r *http.Request, key string, fallback int) int { + value := strings.TrimSpace(r.URL.Query().Get(key)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed < 0 { + return fallback + } + return parsed +} + +func newID(prefix string) string { + buf := make([]byte, 10) + if _, err := rand.Read(buf); err != nil { + return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) + } + return prefix + "_" + hex.EncodeToString(buf) +} + +func extractCode(payload map[string]any) (string, string) { + for _, key := range []string{"code", "verification_code"} { + if value, ok := payload[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value), stringField(payload, "received_at") + } + } + if data, ok := payload["data"].(map[string]any); ok { + for _, key := range []string{"code", "verification_code"} { + if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value), stringField(data, "received_at") + } + } + } + return "", "" +} + +func stringField(payload map[string]any, key string) string { + if value, ok := payload[key].(string); ok { + return value + } + return "" +} diff --git a/mailservice/main_test.go b/mailservice/main_test.go new file mode 100644 index 0000000..52ac396 --- /dev/null +++ b/mailservice/main_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "path/filepath" + "testing" +) + +func TestStorePersistsMailboxBindings(t *testing.T) { + path := filepath.Join(t.TempDir(), "lbms-store.json") + store, err := NewStore(path) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + + mailboxA, bindingA, err := store.LinkOrCreate(LinkOrCreateRequest{ + EmailAddress: "AA@qq.com", + LightBridgeAccountID: 101, + LightBridgePlatform: "openai", + LightBridgeAccountType: "oauth", + LightBridgeAccountName: "OpenAI A", + }) + if err != nil { + t.Fatalf("LinkOrCreate first account: %v", err) + } + if mailboxA.NormalizedEmail != "aa@qq.com" { + t.Fatalf("normalized email = %q", mailboxA.NormalizedEmail) + } + if bindingA.MailboxID != mailboxA.ID { + t.Fatalf("binding mailbox id = %q, want %q", bindingA.MailboxID, mailboxA.ID) + } + + mailboxB, _, err := store.LinkOrCreate(LinkOrCreateRequest{ + EmailAddress: "aa@qq.com", + LightBridgeAccountID: 102, + LightBridgePlatform: "gemini", + LightBridgeAccountType: "oauth", + LightBridgeAccountName: "Gemini B", + }) + if err != nil { + t.Fatalf("LinkOrCreate second account: %v", err) + } + if mailboxB.ID != mailboxA.ID { + t.Fatalf("same email created different mailbox: %q != %q", mailboxB.ID, mailboxA.ID) + } + + reloaded, err := NewStore(path) + if err != nil { + t.Fatalf("NewStore reload: %v", err) + } + mailbox, bindings, ok := reloaded.BindingsByMailbox(mailboxA.ID) + if !ok { + t.Fatalf("mailbox %q was not reloaded", mailboxA.ID) + } + if mailbox.EmailAddress != "AA@qq.com" { + t.Fatalf("email address = %q", mailbox.EmailAddress) + } + if len(bindings) != 2 { + t.Fatalf("binding count = %d, want 2", len(bindings)) + } + if _, _, ok := reloaded.BindingByAccount(101); !ok { + t.Fatalf("account 101 binding was not reloaded") + } + if _, _, ok := reloaded.BindingByAccount(102); !ok { + t.Fatalf("account 102 binding was not reloaded") + } +} + +func TestStoreMovesAccountBetweenMailboxes(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "lbms-store.json")) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + + first, _, err := store.LinkOrCreate(LinkOrCreateRequest{ + EmailAddress: "first@example.com", + LightBridgeAccountID: 201, + LightBridgePlatform: "openai", + LightBridgeAccountType: "oauth", + }) + if err != nil { + t.Fatalf("link first: %v", err) + } + second, _, err := store.LinkOrCreate(LinkOrCreateRequest{ + EmailAddress: "second@example.com", + LightBridgeAccountID: 201, + LightBridgePlatform: "openai", + LightBridgeAccountType: "oauth", + }) + if err != nil { + t.Fatalf("move account: %v", err) + } + if first.ID == second.ID { + t.Fatalf("expected account to move to a different mailbox") + } + + _, oldBindings, ok := store.BindingsByMailbox(first.ID) + if !ok { + t.Fatalf("first mailbox missing") + } + if len(oldBindings) != 0 { + t.Fatalf("old mailbox still has %d bindings", len(oldBindings)) + } + _, newBindings, ok := store.BindingsByMailbox(second.ID) + if !ok { + t.Fatalf("second mailbox missing") + } + if len(newBindings) != 1 || newBindings[0].LightBridgeAccountID != 201 { + t.Fatalf("new mailbox bindings = %#v", newBindings) + } +}