diff --git a/docs/BUG/BUG-00012-邮箱复制功能失效与剪贴板兼容性问题.md b/docs/BUG/BUG-00012-邮箱复制功能失效与剪贴板兼容性问题.md new file mode 100644 index 0000000..2bfa100 --- /dev/null +++ b/docs/BUG/BUG-00012-邮箱复制功能失效与剪贴板兼容性问题.md @@ -0,0 +1,241 @@ +# BUG-00012 邮箱复制功能失效与剪贴板兼容性问题 + +> 关联功能: PRD-00005 / FD-00005 多邮箱统一管理 +> 关联历史缺陷: BUG-00004 临时邮箱复制与邮箱管理交互问题 +> 分析时间: 2026-03-09 +> 状态: 待修复 + +--- + +## 一、问题概述 + +| 编号 | 标题 | 严重程度 | 类型 | +|------|------|---------|------| +| **D-1** | 邮箱管理页账号列表中的邮箱名点击无效 | 🟠 中 | 交互绑定缺失 | +| **D-2** | 复制按钮/当前邮箱栏在部署环境下可能完全无法复制 | 🔴 高 | 剪贴板兼容性 / 前端异常 | + +--- + +## 二、用户反馈现象 + +在 VPS 更新部署后,前端出现以下问题: + +1. 在邮箱管理页点击账号卡片中的邮箱名,没有任何复制效果。 +2. 在邮件列表顶部“当前邮箱”栏点击邮箱名,没有复制效果。 +3. 点击账号卡片右侧的“📋 复制”按钮,也没有完成复制。 + +该问题直接影响账号复制、外部粘贴、手工校验等高频操作。 + +--- + +## 三、复现路径 + +### 3.1 邮箱管理页邮箱名点击无效 + +1. 进入“账号管理”页面。 +2. 选择任一普通邮箱分组。 +3. 在账号卡片中直接点击邮箱地址文本。 +4. 观察:无 toast、无复制结果。 + +### 3.2 当前邮箱栏 / 复制按钮无效 + +1. 在账号管理页选择任一邮箱账号。 +2. 点击顶部“当前邮箱:xxx@xxx.com”。 +3. 或点击账号卡片右下角“📋”复制按钮。 +4. 观察:未复制到系统剪贴板;在部分环境中前端可能静默失败。 + +--- + +## 四、根因分析 + +### 4.1 D-1: 账号列表中的邮箱名没有绑定复制事件 + +邮箱管理页账号卡片由 `renderAccountList()` 动态渲染。当前实现中,邮箱名节点仅负责展示文本,没有绑定 `onclick` 复制逻辑: + +文件: `static/js/features/groups.js` + +```javascript +
+ ${escapeHtml(acc.email)} +
+``` + +对比临时邮箱页面的实现,临时邮箱列表中邮箱名是显式可点击复制的: + +文件: `static/js/features/temp_emails.js` + +```javascript +
${escapeHtml(email.email)}
+``` + +结论: + +- 普通邮箱列表“点击邮箱名可复制”这条交互在当前代码中并未真正实现。 +- 因此前端表现为“点击邮箱名无反应”是代码级确定问题,不依赖浏览器环境。 + +### 4.2 D-2: `copyEmail()` 对 Clipboard API 的调用缺少可用性判断 + +多个复制入口最终都走到了共享函数 `copyEmail()`: + +- 账号卡片复制按钮 `📋` +- 顶部当前邮箱栏 `copyCurrentEmail()` +- 临时邮箱顶部邮箱名 `copyTempEmailCurrent()` + +当前实现如下: + +文件: `static/js/features/emails.js` + +```javascript +function copyEmail(email) { + navigator.clipboard.writeText(email).then(() => { + showToast('邮箱地址已复制', 'success'); + }).catch(() => { + const textarea = document.createElement('textarea'); + textarea.value = email; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + showToast('邮箱地址已复制', 'success'); + }); +} +``` + +这里有两个关键缺陷: + +1. 代码直接调用 `navigator.clipboard.writeText(email)`,没有先判断 `navigator.clipboard` 是否存在。 +2. 当页面运行在非安全上下文(例如 HTTP 部署、浏览器权限策略限制、特定 WebView 环境)时,`navigator.clipboard` 可能为 `undefined`,此时会在 `.then()` 之前直接抛出同步异常,导致后面的 `catch` 降级逻辑根本不会执行。 + +这意味着: + +- 用户点击复制按钮时,前端可能直接报 `TypeError`。 +- 页面上看起来就是“点了没反应”。 +- 降级方案 `document.execCommand('copy')` 实际上并未被触发。 + +### 4.3 降级复制逻辑缺少失败校验,导致问题难以诊断 + +即使进入了 `document.execCommand('copy')` 分支,当前代码也没有检查返回值或捕获异常: + +```javascript +document.execCommand('copy'); +showToast('邮箱地址已复制', 'success'); +``` + +风险: + +- 即便浏览器不支持或执行失败,前端仍可能提示“复制成功”。 +- 这会掩盖真实失败原因,增加线上排查难度。 + +--- + +## 五、影响范围 + +### 5.1 受影响入口 + +| 入口 | 调用链路 | 当前状态 | +|------|---------|---------| +| 账号管理页卡片邮箱名 | `renderAccountList()` | ❌ 未绑定复制事件 | +| 账号管理页卡片复制按钮 | `copyEmail()` | ❌ 在部分部署环境可能失效 | +| 顶部“当前邮箱”栏 | `copyCurrentEmail()` -> `copyEmail()` | ❌ 在部分部署环境可能失效 | +| 临时邮箱页顶部邮箱名 | `copyTempEmailCurrent()` -> `copyEmail()` | ❌ 在部分部署环境可能失效 | +| 临时邮箱卡片复制按钮 | `copyEmail()` | ❌ 在部分部署环境可能失效 | + +### 5.2 受影响文件 + +- `static/js/features/groups.js` +- `static/js/features/emails.js` +- `static/js/features/temp_emails.js` +- `static/js/features/accounts.js` +- `templates/index.html` + +--- + +## 六、疑似触发条件 + +以下场景更容易触发 D-2: + +1. 站点通过 HTTP 暴露,而非 HTTPS。 +2. 通过 Nginx 反代后,实际访问域名不是 `localhost`,浏览器将其视为非安全上下文。 +3. 浏览器或内嵌 WebView 对 Clipboard API 做了权限限制。 +4. 更新后前端脚本命中该路径,但没有在控制台和 UI 中暴露异常信息。 + +说明: + +- D-1 与部署环境无关,任何环境都会复现。 +- D-2 与浏览器上下文、权限模型、部署方式强相关,因此“本地可用、VPS 不可用”是合理现象。 + +--- + +## 七、修复建议 + +### 7.1 修复 D-1: 为普通邮箱列表邮箱名补充点击复制能力 + +建议对齐临时邮箱实现,在账号卡片邮箱名节点上绑定: + +```javascript +onclick="event.stopPropagation(); copyEmail('${escapeJs(acc.email)}')" +``` + +并补充: + +- `cursor:pointer` +- `title="点击复制"` + +### 7.2 修复 D-2: 重写 `copyEmail()`,先检测能力,再降级 + +建议改为显式判断: + +1. 如果 `navigator.clipboard && navigator.clipboard.writeText` 可用,则优先使用 Clipboard API。 +2. 如果不可用,则进入 `textarea + execCommand('copy')` 降级方案。 +3. 如果降级也失败,应明确提示“复制失败”,不要误报成功。 + +建议伪代码: + +```javascript +async function copyEmail(email) { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(email); + showToast('邮箱地址已复制', 'success'); + return true; + } + + const textarea = document.createElement('textarea'); + textarea.value = email; + document.body.appendChild(textarea); + textarea.select(); + const copied = document.execCommand('copy'); + document.body.removeChild(textarea); + + if (!copied) throw new Error('execCommand copy failed'); + + showToast('邮箱地址已复制', 'success'); + return true; + } catch (error) { + console.error('复制邮箱失败:', error); + showToast('复制失败,请手动复制', 'error'); + return false; + } +} +``` + +### 7.3 补充验证 + +建议增加以下回归验证项: + +1. 普通邮箱列表中点击邮箱名可以复制。 +2. 复制按钮可以复制。 +3. 顶部“当前邮箱”栏可以复制。 +4. 临时邮箱页面顶部邮箱名可以复制。 +5. 在 HTTP 部署环境下,降级方案仍可工作,或至少给出明确失败提示。 + +--- + +## 八、结论 + +本次“邮箱复制失效”并非单一问题,而是两个缺陷叠加: + +1. **普通邮箱列表邮箱名本身没有绑定复制事件**,这是确定性代码遗漏。 +2. **共享复制函数对 `navigator.clipboard` 的使用过于乐观**,在 VPS/反代/非 HTTPS 场景下可能直接抛错,导致复制按钮和顶部邮箱栏全部失效。 + +因此该问题应按一个缺陷文档统一跟踪,但修复时需要拆成“交互绑定修复”和“剪贴板兼容性修复”两部分分别验证。 diff --git a/img/ico.png b/img/ico.png new file mode 100644 index 0000000..54deb5d Binary files /dev/null and b/img/ico.png differ diff --git a/outlook_web/controllers/pages.py b/outlook_web/controllers/pages.py index 87dc9ba..7196f6e 100644 --- a/outlook_web/controllers/pages.py +++ b/outlook_web/controllers/pages.py @@ -1,8 +1,9 @@ from __future__ import annotations +from pathlib import Path from typing import Any -from flask import g, jsonify, redirect, render_template, request, session, url_for +from flask import g, jsonify, redirect, render_template, request, send_from_directory, session, url_for from outlook_web.errors import build_error_payload from outlook_web.repositories import settings as settings_repo @@ -106,6 +107,18 @@ def logout() -> Any: return redirect(url_for("pages.login")) +def image_asset(filename: str) -> Any: + """提供仓库 img/ 目录中的静态图片资源。""" + img_dir = Path(__file__).resolve().parents[2] / "img" + return send_from_directory(str(img_dir), filename) + + +def favicon() -> Any: + """站点 favicon,复用 img/ico.png。""" + img_dir = Path(__file__).resolve().parents[2] / "img" + return send_from_directory(str(img_dir), "ico.png", mimetype="image/png") + + @login_required def index() -> Any: """主页""" diff --git a/outlook_web/routes/pages.py b/outlook_web/routes/pages.py index 442b131..737ef4f 100644 --- a/outlook_web/routes/pages.py +++ b/outlook_web/routes/pages.py @@ -21,5 +21,7 @@ def create_blueprint(csrf_exempt: Optional[Callable] = None) -> Blueprint: bp.add_url_rule("/login", view_func=login_view, methods=["GET", "POST"]) bp.add_url_rule("/logout", view_func=pages_controller.logout, methods=["GET"]) bp.add_url_rule("/", view_func=pages_controller.index, methods=["GET"]) + bp.add_url_rule("/img/", view_func=pages_controller.image_asset, methods=["GET"]) + bp.add_url_rule("/favicon.ico", view_func=pages_controller.favicon, methods=["GET"]) bp.add_url_rule("/api/csrf-token", view_func=csrf_token_view, methods=["GET"]) return bp diff --git a/static/js/features/emails.js b/static/js/features/emails.js index acb5132..35f3841 100644 --- a/static/js/features/emails.js +++ b/static/js/features/emails.js @@ -753,19 +753,39 @@ } // 复制邮箱地址 - function copyEmail(email) { - navigator.clipboard.writeText(email).then(() => { - showToast('邮箱地址已复制', 'success'); - }).catch(() => { - // 降级方案 + async function copyEmail(email) { + try { + if (navigator.clipboard && navigator.clipboard.writeText && window.isSecureContext) { + await navigator.clipboard.writeText(email); + showToast('邮箱地址已复制', 'success'); + return true; + } + const textarea = document.createElement('textarea'); textarea.value = email; + textarea.setAttribute('readonly', 'readonly'); + textarea.style.position = 'fixed'; + textarea.style.top = '-9999px'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.focus(); textarea.select(); - document.execCommand('copy'); + + const copied = document.execCommand('copy'); document.body.removeChild(textarea); + + if (!copied) { + throw new Error('document.execCommand(copy) returned false'); + } + showToast('邮箱地址已复制', 'success'); - }); + return true; + } catch (error) { + console.error('复制邮箱地址失败:', error); + showToast('复制失败,请手动复制', 'error'); + return false; + } } // 复制当前邮箱 @@ -780,9 +800,7 @@ // 退出登录 function logout() { if (confirm('确定要退出登录吗?')) { - fetch('/logout', { method: 'POST' }) - .then(() => { window.location.href = '/login'; }) - .catch(() => { window.location.href = '/logout'; }); + window.location.href = '/logout'; } } diff --git a/static/js/features/groups.js b/static/js/features/groups.js index c8fde89..6415ab0 100644 --- a/static/js/features/groups.js +++ b/static/js/features/groups.js @@ -264,7 +264,10 @@ onclick="event.stopPropagation(); updateBatchActionBar(); updateSelectAllCheckbox()">
${initial}
-