dsh-conversation-recycle-bin 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,27 @@
3
3
  All notable changes to `dsh-conversation-recycle-bin` are documented here.
4
4
  The format follows Keep a Changelog; the project uses Semantic Versioning.
5
5
 
6
+ ## [0.3.1] — 2026-10-18
7
+
8
+ ### Added
9
+
10
+ - **表单改为让用户填写自己的邮箱**:反馈对话框里原来的「联系方式」换成「你的邮箱(选填)」`type="email"` 输入框,客户端与 Host 双重校验(必须是 `a@b.cc` 形状、≤200 字)。格式不合法直接以 `400 E_FEEDBACK_INVALID` + `details.field === 'email'` 拒绝,而不是默默发一封无法回复的邮件。
11
+ - 新增配置项 **`feedback.visitorAsFrom`**(默认 `false`):访客填写的邮箱默认写入 `Reply-To`,并在正文的 `from / 发件人` 行展示;设为 `true` 时同时作为 `From`——只有允许任意发件人的中继才行,QQ/163/Gmail 会拒收(要求 From 与登录账号一致)。`/state.feedback.visitorAsFrom` 会同步给前端,用于切换输入框下方的提示文案。
12
+
13
+ ### Changed
14
+
15
+ - 邮件正文上下文字段由 `contact:` 改为 `from / 发件人:` + `reply-to:` 两行;HTTP 通道的 `reply_to` 取自访客邮箱。
16
+ - `test/feedback.test.mjs` 16 → 17 个用例(新增 `resolveSender` 发件人策略单测、非法邮箱拒绝);`test/client-module.test.mjs` 20 → 19(删除一条逐字节重复的用例外壳,行为覆盖不变),全量仍为 122。`README.md` §9.2 增加 `visitorAsFrom` 行与「发件人不是访客邮箱」排查项;`config.schema.json` / `cordis.patch.yml` 同步新键。
17
+
18
+ ### Fixed
19
+
20
+ - **删除当前会话后的结果对话框会被兜底刷新抹掉**:Host 无法原地关闭该会话时会 arm 一次兜底刷新,原来是固定 900ms 后 `location.reload()`,弹窗里的「部分失败明细」根本来不及看。现在结果对话框会等用户关闭(按钮 / Escape / 点遮罩)后再刷新,5s 只是对话框被晾着的安全网;关闭时还会 `clearTimeout` 掉安全网计时器,避免关掉之后又被刷一次。对应文案 `dialog.reloadHint` 由「会话列表将自动刷新」改为「关闭本对话框后会刷新会话列表」。
21
+ - **反馈表单的邮箱校验顺序与 Host 相反**:客户端先判超长再判格式,300 字的地址会提示「不是邮箱」,而 Host 对同一次提交回的是「太长」。现在两边统一为「先形状、后长度」(对齐 `lib/host/feedback.js:241-250`)。
22
+ - **「记忆不可用」的原因混进英文硬编码**:无可用存储时 `not detected` 直接拼进中文文案。改用词条 `state.notDetected`(中英各一条)。
23
+ - **反馈表单的校验提示只在「输入过」时出现**:只用 Tab 扫过表单、一个字符没改的用户会看到一个禁用的提交按钮却看不到原因。现在「改过」或「离开过任一字段」(`dirty || touched`)即显示提示。
24
+ - **`noValidate` 挂在 `div` 上**:这是 form 的属性,放在对话框的 `div` 上无效,属误导性死属性,已删除(本组件不含 `<form>`,本身也不会触发原生校验)。
25
+ - **`test/client-module.test.mjs` 里有一条逐字节重复的用例**:同一段 `test('client: the active locale …')` 出现两次(HEAD 即如此),实际独立用例 19 条而计数写 20。删掉重复的一份,覆盖不变。
26
+
6
27
  ## [0.3.0] — 2026-10-18
7
28
 
8
29
  ### Added
@@ -15,7 +36,7 @@ The format follows Keep a Changelog; the project uses Semantic Versioning.
15
36
 
16
37
  ### Changed
17
38
 
18
- - `npm test` 增加 `test/feedback.test.mjs`(15 个用例:配置归一化、通道判定、校验/渲染、HTTP 通路、失败状态、限流窗口、未配置、SMTP 缺依赖、两条路由用例),用例总数 106 → 121;`npm run check` 增加 `lib/host/feedback.js` 语法检查。
39
+ - `npm test` 增加 `test/feedback.test.mjs`(16 个用例:配置归一化、通道判定、校验/渲染、HTTP 通路、失败状态、限流窗口、未配置、SMTP 缺依赖、两条路由用例),用例总数 104 → 122(按 `^test(` 逐文件实测校正;本行原写 106 → 121 为陈旧错值);`npm run check` 增加 `lib/host/feedback.js` 语法检查。
19
40
  - `cordis.patch.yml` / `config.schema.json` 增加 `feedback` 默认段(`to` 默认为维护者公开反馈邮箱、`from` 留空、`transport: auto`、`rateLimitPerHour: 5`)。
20
41
 
21
42
  ## [0.2.2] — 2026-10-18
package/README.md CHANGED
@@ -190,7 +190,7 @@ dsh-conversation-recycle-bin/
190
190
  │ ├─ memory.js # 适配器装配
191
191
  │ ├─ feedback.js # 反馈校验/渲染/投递(http 或 smtp,凭证只读环境变量)
192
192
  │ └─ api.js # 同源 HTTP API、确认 token、限流
193
- └─ test/ # 121 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
193
+ └─ test/ # 122 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
194
194
  ```
195
195
 
196
196
  ---
@@ -199,7 +199,7 @@ dsh-conversation-recycle-bin/
199
199
 
200
200
  ```powershell
201
201
  cd D:\Users\34332\AI\dsh-conversation-recycle-bin
202
- npm test # = manifest && client-module && core && cascade && memory && trash-audit && feedback && api(121 个用例)
202
+ npm test # = manifest && client-module && core && cascade && memory && trash-audit && feedback && api(122 个用例)
203
203
  npm run check # 语法 + manifest
204
204
  ```
205
205
 
@@ -236,7 +236,7 @@ npm run check # 语法 + manifest
236
236
 
237
237
  ### 9.1 为什么是「页面 → 宿主路由 → 邮件」三段
238
238
 
239
- 浏览器既不能开 SMTP 连接,也不该持有邮件服务凭证。因此 `client.js` 只负责表单与校验提示,`POST /conversation-recycle-bin/api/feedback` 由 `lib/host/feedback.js` 校验、渲染并投递。表单里的「类型 / 标题 / 正文 / 联系方式」之外,只额外附带**对话框里已展示给用户**的上下文(插件版本、平台、语言);对话内容、会话 id、工作区路径都不会进入邮件。
239
+ 浏览器既不能开 SMTP 连接,也不该持有邮件服务凭证。因此 `client.js` 只负责表单与校验提示,`POST /conversation-recycle-bin/api/feedback` 由 `lib/host/feedback.js` 校验、渲染并投递。表单里的「类型 / 标题 / 正文 / 你的邮箱」之外,只额外附带**对话框里已展示给用户**的上下文(插件版本、平台、语言);对话内容、会话 id、工作区路径都不会进入邮件。
240
240
 
241
241
  ### 9.2 配置项(`feedback` 段,非机密)
242
242
 
@@ -247,6 +247,7 @@ npm run check # 语法 + manifest
247
247
  | `feedback.from` | `''` | 发件人。留空时 SMTP 通道复用连接串里的用户名(服务商通常要求 From 与登录账号一致);`DSH_CRB_FEEDBACK_FROM` 优先于它 |
248
248
  | `feedback.transport` | `auto` | `auto` = 有 `DSH_CRB_MAIL_API_URL` 走 http,否则有 `DSH_CRB_SMTP_URL` 走 smtp;`http`/`smtp` 强制单通道(配不全就报未配置,不会静默换道);`off` 关闭 |
249
249
  | `feedback.rateLimitPerHour` | `5` | 每宿主每小时发送上限(1–100)。邮件从操作者自己的账号发出,默认值刻意压低 |
250
+ | `feedback.visitorAsFrom` | `false` | 访客在表单里填的**自己的邮箱**默认只作为 `Reply-To`(并在正文里列出),`From` 仍是发信账号。设为 `true` 时把访客邮箱放进 `From`——只有允许任意发件人的中继才行,QQ/163/Gmail 会拒收(它们要求 From 与登录账号一致) |
250
251
 
251
252
  ### 9.3 环境变量(优先级高于配置项;凭证只走这里,不进配置文件)
252
253
 
@@ -261,7 +262,7 @@ npm run check # 语法 + manifest
261
262
  | `DSH_CRB_MAIL_API_AUTH_HEADER` | 可选,鉴权头名,默认 `authorization`,值为 `Bearer <token>` |
262
263
  | `DSH_CRB_SMTP_URL` | SMTP 连接串,如 `smtps://user:pass@smtp.example.com:465` |
263
264
 
264
- HTTP 通道发出的 JSON 固定为 `{from, to: [收件人], subject, text, reply_to}`,`text` 是纯文本(不生成 HTML);`reply_to` 只在联系方式看起来可用时填写。SMTP 通道按需 `import('nodemailer')`:它已声明为 `optionalDependencies`(`^10.1.2`),正常安装会随包就位;以 `link:` 方式开发安装时插件从自身真实目录解析,需在**本仓库**跑一次 `npm install`。仍未安装时返回 `E_FEEDBACK_TRANSPORT` + `reason: nodemailer-missing`。
265
+ HTTP 通道发出的 JSON 固定为 `{from, to: [收件人], subject, text, reply_to}`,`text` 是纯文本(不生成 HTML);`reply_to` 取自表单里访客填写的邮箱(留空则不填)。SMTP 通道按需 `import('nodemailer')`:它已声明为 `optionalDependencies`(`^10.1.2`),正常安装会随包就位;以 `link:` 方式开发安装时插件从自身真实目录解析,需在**本仓库**跑一次 `npm install`。仍未安装时返回 `E_FEEDBACK_TRANSPORT` + `reason: nodemailer-missing`。
265
266
 
266
267
  #### 9.3.1 QQ 邮箱开通示例
267
268
 
@@ -286,8 +287,8 @@ node -e "console.log('b64:' + Buffer.from('your-feedback@example.com','utf8').to
286
287
  ```powershell
287
288
  cd D:\Users\34332\AI\dsh-conversation-recycle-bin
288
289
  npm run check # 语法 + manifest(含 feedback.js)
289
- node test/feedback.test.mjs # 15 个反馈用例
290
- npm test # 全量 121 个用例
290
+ node test/feedback.test.mjs # 17 个反馈用例
291
+ npm test # 全量 122 个用例
291
292
 
292
293
  # 端到端:用本机收件桩观察真实投递路径,不外发邮件
293
294
  $env:DSH_CRB_MAIL_API_URL = 'http://127.0.0.1:9099/emails'
@@ -305,9 +306,10 @@ $env:DSH_CRB_FEEDBACK_TO = 'feedback@example.test'
305
306
  |---|---|---|
306
307
  | 设置页没有「反馈」按钮 | 通道未配置(`/state.feedback.enabled === false`) | 设好 `DSH_CRB_MAIL_API_URL` + token,或 `DSH_CRB_SMTP_URL`;确认 `feedback.transport` 不是 `off`、`enabled` 不是 `false` |
307
308
  | 点击后 503 `E_FEEDBACK_DISABLED` | 同上一行(按钮来自旧页面渲染) | 刷新页面;看 `/state.feedback.reason`:`not-configured` / `disabled` / `off` |
308
- | 400 `E_FEEDBACK_INVALID` | 标题 4–120 字、正文 10–4000 字、联系方式 ≤200 字不满足 | `details.field` 指明字段,`details.min`/`details.max` 给出边界 |
309
+ | 400 `E_FEEDBACK_INVALID` | 标题 4–120 字、正文 10–4000 字、邮箱 ≤200 字且必须形如 `a@b.cc` | `details.field` 指明字段,`details.min`/`details.max` 给出边界 |
309
310
  | 429 `E_FEEDBACK_RATE_LIMITED` | 超过 `feedback.rateLimitPerHour` | 等 `details.retryAfterMs` 后重试,或调高上限 |
310
311
  | 502 `E_FEEDBACK_TRANSPORT` | 邮件服务返回非 2xx / 网络失败 / 未装 nodemailer / 暴露不出 `createTransport` | 看宿主日志 `[conversation-recycle-bin] feedback delivery failed (<transport>): <code> <message>`(只含错误码与通道,不含正文);核对 URL、token、SMTP 授权码 |
312
+ | 邮件里「发件人」不是访客的邮箱 | 默认只把访客邮箱放进 `Reply-To`(`visitorAsFrom: false`),发信人仍是发信账号 | QQ/163/Gmail 必须如此(From 必须等于登录账号);换用允许任意发件人的中继后再把 `feedback.visitorAsFrom` 设为 `true` |
311
313
  | 接口 200 但收件箱没有 | 服务端已受理,但被对方退信或进了垃圾箱 | 查发件账号退信;`feedback.from` 用与 SMTP 账号一致的地址,并检查收件人域名 |
312
314
 
313
315
  > 隐私边界:邮件正文 = 用户输入 + 对话框里已展示的上下文;宿主日志只记错误码、通道与耗时。删除开关(`enabled`)与反馈通道互相独立——关掉删除不影响反馈,反之亦然。
package/client.js CHANGED
@@ -106,7 +106,7 @@ window.__ModuleLoader__.load({
106
106
  'dialog.partial': 'Deleted {done}, failed {failed}.',
107
107
  'dialog.noSession': 'The conversation no longer exists.',
108
108
  'dialog.blocked': 'This conversation cannot be deleted: {message}',
109
- 'dialog.reloadHint': 'The conversation list refreshes automatically.',
109
+ 'dialog.reloadHint': 'The conversation list refreshes when you close this dialog.',
110
110
  'trash.title': 'Recycle bin',
111
111
  'trash.empty': 'The recycle bin is empty.',
112
112
  'trash.deletedAt': 'Deleted {time}',
@@ -145,6 +145,7 @@ window.__ModuleLoader__.load({
145
145
  'state.limits': 'Limits',
146
146
  'state.available': 'available',
147
147
  'state.unavailable': 'unavailable',
148
+ 'state.notDetected': 'not detected',
148
149
  'state.rate': '{used}/{limit} deletions this minute',
149
150
  'state.batch': 'Effective batch limit: {limit}',
150
151
  'feedback.button': 'Feedback',
@@ -160,10 +161,15 @@ window.__ModuleLoader__.load({
160
161
  'feedback.titlePlaceholder': 'One line that summarises it',
161
162
  'feedback.bodyLabel': 'Message',
162
163
  'feedback.bodyPlaceholder': 'What happened, what you expected, and the steps that led to it.',
163
- 'feedback.contactLabel': 'Contact (optional)',
164
- 'feedback.contactPlaceholder': 'An email address or another way to reach you',
164
+ 'feedback.emailLabel': 'Your email (optional)',
165
+ 'feedback.emailPlaceholder': 'you@example.com',
166
+ 'feedback.emailHint': 'Used as Reply-To so the maintainer can answer you. Leave it empty to stay anonymous.',
167
+ 'feedback.emailHintAsFrom': 'Shown as the sender (From) of the mail.',
165
168
  'feedback.contextLabel': 'Attached automatically',
166
169
  'feedback.contextHint': 'Plugin version, platform and language. Conversation content is never attached.',
170
+ 'feedback.contextVersion': 'Plugin version',
171
+ 'feedback.contextPlatform': 'Platform',
172
+ 'feedback.contextLocale': 'Language',
167
173
  'feedback.send': 'Send',
168
174
  'feedback.sending': 'Sending…',
169
175
  'feedback.sent': 'Feedback sent. Thank you!',
@@ -171,7 +177,8 @@ window.__ModuleLoader__.load({
171
177
  'feedback.tooLongTitle': 'The title may be at most {max} characters.',
172
178
  'feedback.tooShortBody': 'The message needs at least {min} characters.',
173
179
  'feedback.tooLongBody': 'The message may be at most {max} characters.',
174
- 'feedback.tooLongContact': 'The contact field may be at most {max} characters.',
180
+ 'feedback.invalidEmail': 'That does not look like an email address.',
181
+ 'feedback.tooLongEmail': 'The email address may be at most {max} characters.',
175
182
  'feedback.unavailable': 'The feedback channel is not configured on this Host.',
176
183
  'error.E_FEEDBACK_DISABLED': 'The feedback channel is not configured on this Host.',
177
184
  'error.E_FEEDBACK_INVALID': 'The feedback form is incomplete or too long.',
@@ -252,7 +259,7 @@ window.__ModuleLoader__.load({
252
259
  'dialog.partial': '成功 {done} 个,失败 {failed} 个。',
253
260
  'dialog.noSession': '该对话已不存在。',
254
261
  'dialog.blocked': '该对话无法删除:{message}',
255
- 'dialog.reloadHint': '会话列表将自动刷新。',
262
+ 'dialog.reloadHint': '关闭本对话框后会刷新会话列表。',
256
263
  'trash.title': '回收站',
257
264
  'trash.empty': '回收站为空。',
258
265
  'trash.deletedAt': '删除于 {time}',
@@ -291,6 +298,7 @@ window.__ModuleLoader__.load({
291
298
  'state.limits': '限制',
292
299
  'state.available': '可用',
293
300
  'state.unavailable': '不可用',
301
+ 'state.notDetected': '未检测到',
294
302
  'state.rate': '本分钟已删除 {used}/{limit}',
295
303
  'state.batch': '单次上限:{limit} 个(受每分钟额度约束)',
296
304
  'feedback.button': '反馈',
@@ -306,10 +314,15 @@ window.__ModuleLoader__.load({
306
314
  'feedback.titlePlaceholder': '用一句话概括',
307
315
  'feedback.bodyLabel': '正文',
308
316
  'feedback.bodyPlaceholder': '发生了什么、预期是什么、如何复现。',
309
- 'feedback.contactLabel': '联系方式(选填)',
310
- 'feedback.contactPlaceholder': '邮箱或其他能找到你的方式',
317
+ 'feedback.emailLabel': '你的邮箱(选填)',
318
+ 'feedback.emailPlaceholder': 'you@example.com',
319
+ 'feedback.emailHint': '作为 Reply-To,便于维护者直接回复你;留空即匿名。',
320
+ 'feedback.emailHintAsFrom': '会直接作为这封邮件的发件人。',
311
321
  'feedback.contextLabel': '自动附带',
312
322
  'feedback.contextHint': '插件版本、平台与语言;不会附带任何对话内容。',
323
+ 'feedback.contextVersion': '插件版本',
324
+ 'feedback.contextPlatform': '平台',
325
+ 'feedback.contextLocale': '语言',
313
326
  'feedback.send': '发送',
314
327
  'feedback.sending': '正在发送…',
315
328
  'feedback.sent': '反馈已发送,谢谢!',
@@ -317,7 +330,8 @@ window.__ModuleLoader__.load({
317
330
  'feedback.tooLongTitle': '标题最多 {max} 个字符。',
318
331
  'feedback.tooShortBody': '正文至少需要 {min} 个字符。',
319
332
  'feedback.tooLongBody': '正文最多 {max} 个字符。',
320
- 'feedback.tooLongContact': '联系方式最多 {max} 个字符。',
333
+ 'feedback.invalidEmail': '这看起来不是一个邮箱地址。',
334
+ 'feedback.tooLongEmail': '邮箱最多 {max} 个字符。',
321
335
  'feedback.unavailable': '当前宿主未配置反馈通道。',
322
336
  'error.E_FEEDBACK_DISABLED': '当前宿主未配置反馈通道。',
323
337
  'error.E_FEEDBACK_INVALID': '反馈内容不完整或超出长度限制。',
@@ -544,11 +558,14 @@ window.__ModuleLoader__.load({
544
558
 
545
559
  const flashStore = {
546
560
  message: null,
561
+ timer: null,
547
562
  listeners: new Set(),
548
563
  show(message) {
564
+ if (this.timer !== null) clearTimeout(this.timer)
549
565
  this.message = message
550
566
  for (const listener of this.listeners) listener(this.message)
551
- setTimeout(() => {
567
+ this.timer = setTimeout(() => {
568
+ this.timer = null
552
569
  if (this.message === message) {
553
570
  this.message = null
554
571
  for (const listener of this.listeners) listener(null)
@@ -650,7 +667,35 @@ window.__ModuleLoader__.load({
650
667
 
651
668
  function reloadApp(delay = 900) {
652
669
  if (typeof window === 'undefined' || typeof window.location?.reload !== 'function') return
653
- window.setTimeout(() => window.location.reload(), delay)
670
+ reloadTimer = window.setTimeout(() => {
671
+ reloadTimer = null
672
+ reloadPending = false
673
+ window.location.reload()
674
+ }, delay)
675
+ }
676
+
677
+ // A fallback reload is armed when the Host cannot close the deleted
678
+ // conversation in place. The result dialog is the only place a partial
679
+ // batch reports its per-session failures, so the reload waits for the user
680
+ // to dismiss it; the timer is only a safety net for a dialog left open.
681
+ const RELOAD_FALLBACK_DELAY = 5_000
682
+ let reloadPending = false
683
+ let reloadTimer = null
684
+ // Set when the user dismisses the result dialog. `refreshAfterMutation`
685
+ // settles after the dialog is already on screen, so the dismissal can
686
+ // arrive first; the reload then happens at once instead of arming a timer.
687
+ let reloadDialogDismissed = false
688
+
689
+ function takeReloadPending() {
690
+ const pending = reloadPending
691
+ reloadPending = false
692
+ // Cancel the safety-net timer, otherwise dismissing the dialog would
693
+ // reload once now and once again when the original timer fires.
694
+ if (pending && reloadTimer !== null) {
695
+ if (typeof window !== 'undefined') window.clearTimeout?.(reloadTimer)
696
+ reloadTimer = null
697
+ }
698
+ return pending
654
699
  }
655
700
 
656
701
  function service(name) {
@@ -687,7 +732,7 @@ window.__ModuleLoader__.load({
687
732
  async function refreshAfterMutation(options = {}) {
688
733
  const sessionIds = Array.isArray(options.sessionIds) ? options.sessionIds : []
689
734
  const closeCurrent = options.closeCurrent !== false
690
- const fallbackDelay = Number.isFinite(options.fallbackDelay) ? options.fallbackDelay : 900
735
+ const fallbackDelay = Number.isFinite(options.fallbackDelay) ? options.fallbackDelay : RELOAD_FALLBACK_DELAY
691
736
  const current = openSessionId()
692
737
  const sessions = service('sessions')
693
738
  let refreshed = false
@@ -725,7 +770,13 @@ window.__ModuleLoader__.load({
725
770
  }
726
771
  }
727
772
  }
728
- if (!refreshed) reloadApp(fallbackDelay)
773
+ if (!refreshed) {
774
+ if (reloadDialogDismissed) reloadApp(0)
775
+ else {
776
+ reloadPending = true
777
+ reloadApp(fallbackDelay)
778
+ }
779
+ }
729
780
  if (options.notify !== false) mutationStore.bump()
730
781
  return refreshed
731
782
  }
@@ -828,22 +879,29 @@ window.__ModuleLoader__.load({
828
879
  titleMax: 120,
829
880
  bodyMin: 10,
830
881
  bodyMax: 4_000,
831
- contactMax: 200,
882
+ emailMax: 200,
832
883
  })
833
884
 
885
+ /** Same loose shape the Host accepts: `a@b.cc`, no whitespace or separators. */
886
+ const EMAIL_PATTERN = /^[^\s@,;:<>()[\]\\]+@[^\s@,;:<>()[\]\\]+\.[^\s@,;:<>()[\]\\]{2,}$/
887
+
834
888
  /**
835
- * @param {{title?: string, body?: string, contact?: string}} input
889
+ * @param {{title?: string, body?: string, email?: string}} input
836
890
  * @returns {{ok: true} | {ok: false, key: string, vars: Record<string, number>}}
837
891
  */
838
892
  function validateFeedbackInput(input) {
839
893
  const title = String(input?.title ?? '').trim()
840
894
  const body = String(input?.body ?? '').trim()
841
- const contact = String(input?.contact ?? '').trim()
895
+ const email = String(input?.email ?? '').trim()
842
896
  if (title.length < FEEDBACK_LIMITS.titleMin) return { ok: false, key: 'feedback.tooShortTitle', vars: { min: FEEDBACK_LIMITS.titleMin } }
843
897
  if (title.length > FEEDBACK_LIMITS.titleMax) return { ok: false, key: 'feedback.tooLongTitle', vars: { max: FEEDBACK_LIMITS.titleMax } }
844
898
  if (body.length < FEEDBACK_LIMITS.bodyMin) return { ok: false, key: 'feedback.tooShortBody', vars: { min: FEEDBACK_LIMITS.bodyMin } }
845
899
  if (body.length > FEEDBACK_LIMITS.bodyMax) return { ok: false, key: 'feedback.tooLongBody', vars: { max: FEEDBACK_LIMITS.bodyMax } }
846
- if (contact.length > FEEDBACK_LIMITS.contactMax) return { ok: false, key: 'feedback.tooLongContact', vars: { max: FEEDBACK_LIMITS.contactMax } }
900
+ // Same order as the Host (`lib/host/feedback.js:241-250`): shape first,
901
+ // then length. The reverse told a 300-character address it was "not an
902
+ // email" while the Host said "too long" for the identical submission.
903
+ if (email !== '' && !EMAIL_PATTERN.test(email)) return { ok: false, key: 'feedback.invalidEmail', vars: {} }
904
+ if (email.length > FEEDBACK_LIMITS.emailMax) return { ok: false, key: 'feedback.tooLongEmail', vars: { max: FEEDBACK_LIMITS.emailMax } }
847
905
  return { ok: true }
848
906
  }
849
907
 
@@ -878,6 +936,15 @@ window.__ModuleLoader__.load({
878
936
  const [error, setError] = useState(null)
879
937
  const [phrase, setPhrase] = useState('')
880
938
  const [done, setDone] = useState(null)
939
+
940
+ // Dismissing the result must also complete a reload the Host armed
941
+ // because it cannot close the deleted conversation in place; otherwise
942
+ // the safety-net timer would fire later and reload under the user's feet.
943
+ function closeDone() {
944
+ reloadDialogDismissed = true
945
+ dialogStore.close()
946
+ if (takeReloadPending()) reloadApp(0)
947
+ }
881
948
  const requestRef = useRef(0)
882
949
 
883
950
  const load = useCallback(async (nextMode, nextMemory, options = {}) => {
@@ -909,7 +976,9 @@ window.__ModuleLoader__.load({
909
976
 
910
977
  useEffect(() => {
911
978
  function onKey(event) {
912
- if (event.key === 'Escape' && busy !== true) dialogStore.close()
979
+ // Escape dismisses the result dialog too, so it must release the
980
+ // armed reload exactly like the Close button does.
981
+ if (event.key === 'Escape' && busy !== true) closeDone()
913
982
  }
914
983
  window.addEventListener('keydown', onKey)
915
984
  return () => window.removeEventListener('keydown', onKey)
@@ -935,6 +1004,9 @@ window.__ModuleLoader__.load({
935
1004
 
936
1005
  async function submit() {
937
1006
  if (confirmation === null || busy) return
1007
+ // A new submission starts a new result cycle: a dismissal from the
1008
+ // previous one must not force this run's reload to happen at once.
1009
+ reloadDialogDismissed = false
938
1010
  setBusy(true)
939
1011
  setError(null)
940
1012
  try {
@@ -985,7 +1057,10 @@ window.__ModuleLoader__.load({
985
1057
  }
986
1058
 
987
1059
  if (done !== null) {
988
- return h('div', { className: 'dc-backdrop' },
1060
+ return h('div', {
1061
+ className: 'dc-backdrop',
1062
+ onMouseDown: (event) => { if (event.target === event.currentTarget) closeDone() },
1063
+ },
989
1064
  h('div', { className: 'dc-dialog', role: 'dialog', 'aria-modal': 'true' },
990
1065
  h('h2', { className: 'dc-dialog-title' }, t('dialog.done', { count: done.count, memory: done.memory })),
991
1066
  done.failed > 0 ? h('p', { className: 'dc-error-text' }, t('dialog.partial', { done: done.count, failed: done.failed })) : null,
@@ -996,7 +1071,7 @@ window.__ModuleLoader__.load({
996
1071
  : null,
997
1072
  h('p', { className: 'dc-hint' }, t('dialog.reloadHint')),
998
1073
  h('div', { className: 'dc-actions' },
999
- h(Button, { variant: 'primary', onClick: () => { dialogStore.close() } }, t('action.close')))))
1074
+ h(Button, { variant: 'primary', onClick: () => closeDone() }, t('action.close')))))
1000
1075
  }
1001
1076
 
1002
1077
  return h('div', {
@@ -1103,7 +1178,10 @@ window.__ModuleLoader__.load({
1103
1178
  if (first !== undefined) return adapterReason(first.reason)
1104
1179
  const stores = Array.isArray(memory?.stores) ? memory.stores : []
1105
1180
  const store = stores.find((item) => item.available === false && typeof item.reason === 'string')
1106
- return store === undefined ? 'not detected' : adapterReason(store.reason)
1181
+ // `not detected` used to be hard-coded English, so a Chinese page showed
1182
+ // `记忆存储不可用:not detected`. Everything else here already routes
1183
+ // through the string table.
1184
+ return store === undefined ? t('state.notDetected') : adapterReason(store.reason)
1107
1185
  }
1108
1186
 
1109
1187
  /* ------------------------------------------------------------------ *
@@ -1124,15 +1202,22 @@ window.__ModuleLoader__.load({
1124
1202
  const [category, setCategory] = useState('bug')
1125
1203
  const [title, setTitle] = useState('')
1126
1204
  const [body, setBody] = useState('')
1127
- const [contact, setContact] = useState('')
1205
+ const [email, setEmail] = useState('')
1128
1206
  const [busy, setBusy] = useState(false)
1129
1207
  const [error, setError] = useState(null)
1130
- const validation = validateFeedbackInput({ title, body, contact })
1208
+ const validation = validateFeedbackInput({ title, body, email })
1131
1209
  const context = collectFeedbackContext()
1132
1210
  // A Host without a mail transport advertises `enabled: false`; the form is
1133
1211
  // still rendered read-only so the explanation is visible instead of a
1134
1212
  // button that fails on click.
1135
1213
  const unavailable = feedback?.enabled !== true
1214
+ // Nothing has been typed on the first render, so the "too short" rule must
1215
+ // not greet the user. It starts applying once the user edits a field OR
1216
+ // leaves one: gating on `dirty` alone left the disabled submit button with
1217
+ // no visible reason for anyone who only tabbed through the form.
1218
+ const [dirty, setDirty] = useState(false)
1219
+ const [touched, setTouched] = useState(false)
1220
+ const validationVisible = (dirty === true || touched === true) && validation.ok !== true
1136
1221
 
1137
1222
  useEffect(() => {
1138
1223
  function onKey(event) {
@@ -1154,7 +1239,7 @@ window.__ModuleLoader__.load({
1154
1239
  category,
1155
1240
  title: title.trim(),
1156
1241
  body: body.trim(),
1157
- contact: contact.trim(),
1242
+ email: email.trim(),
1158
1243
  context: {
1159
1244
  pluginVersion: version,
1160
1245
  platform: context.platform,
@@ -1192,7 +1277,8 @@ window.__ModuleLoader__.load({
1192
1277
  className: 'dc-input',
1193
1278
  value: category,
1194
1279
  disabled: busy,
1195
- onChange: (event) => setCategory(event.target.value),
1280
+ onBlur: () => setTouched(true),
1281
+ onChange: (event) => { setDirty(true); setCategory(event.target.value) },
1196
1282
  },
1197
1283
  h('option', { value: 'bug' }, t('feedback.categoryBug')),
1198
1284
  h('option', { value: 'suggestion' }, t('feedback.categorySuggestion')),
@@ -1208,7 +1294,8 @@ window.__ModuleLoader__.load({
1208
1294
  placeholder: t('feedback.titlePlaceholder'),
1209
1295
  disabled: busy,
1210
1296
  autoFocus: true,
1211
- onChange: (event) => setTitle(event.target.value),
1297
+ onBlur: () => setTouched(true),
1298
+ onChange: (event) => { setDirty(true); setTitle(event.target.value) },
1212
1299
  })),
1213
1300
 
1214
1301
  h('label', { className: 'dc-field' },
@@ -1220,30 +1307,34 @@ window.__ModuleLoader__.load({
1220
1307
  maxLength: FEEDBACK_LIMITS.bodyMax,
1221
1308
  placeholder: t('feedback.bodyPlaceholder'),
1222
1309
  disabled: busy,
1223
- onChange: (event) => setBody(event.target.value),
1310
+ onBlur: () => setTouched(true),
1311
+ onChange: (event) => { setDirty(true); setBody(event.target.value) },
1224
1312
  })),
1225
1313
 
1226
1314
  h('label', { className: 'dc-field' },
1227
- h('span', { className: 'dc-field-label' }, t('feedback.contactLabel')),
1315
+ h('span', { className: 'dc-field-label' }, t('feedback.emailLabel')),
1228
1316
  h('input', {
1229
- type: 'text',
1317
+ type: 'email',
1230
1318
  className: 'dc-input',
1231
- value: contact,
1232
- maxLength: FEEDBACK_LIMITS.contactMax,
1233
- placeholder: t('feedback.contactPlaceholder'),
1319
+ value: email,
1320
+ maxLength: FEEDBACK_LIMITS.emailMax,
1321
+ placeholder: t('feedback.emailPlaceholder'),
1322
+ autoComplete: 'email',
1234
1323
  disabled: busy,
1235
- onChange: (event) => setContact(event.target.value),
1236
- })),
1324
+ onBlur: () => setTouched(true),
1325
+ onChange: (event) => { setDirty(true); setEmail(event.target.value) },
1326
+ }),
1327
+ h('div', { className: 'dc-hint' }, t(feedback?.visitorAsFrom === true ? 'feedback.emailHintAsFrom' : 'feedback.emailHint'))),
1237
1328
 
1238
1329
  h('div', { className: 'dc-context' },
1239
1330
  h('div', { className: 'dc-context-title' }, t('feedback.contextLabel')),
1240
1331
  h('div', { className: 'dc-hint' }, t('feedback.contextHint')),
1241
1332
  h('ul', { className: 'dc-context-list' },
1242
- h('li', null, `plugin: ${version === '' ? '—' : version}`),
1243
- h('li', null, `platform: ${context.platform === '' ? '—' : context.platform}`),
1244
- h('li', null, `locale: ${context.locale}`))),
1333
+ h('li', null, `${t('feedback.contextVersion')}: ${version === '' ? '—' : version}`),
1334
+ h('li', null, `${t('feedback.contextPlatform')}: ${context.platform === '' ? '—' : context.platform}`),
1335
+ h('li', null, `${t('feedback.contextLocale')}: ${context.locale}`))),
1245
1336
 
1246
- validation.ok !== true ? h('div', { className: 'dc-hint' }, t(validation.key, validation.vars)) : null,
1337
+ validationVisible ? h('div', { className: 'dc-hint' }, t(validation.key, validation.vars)) : null,
1247
1338
  busy ? h(Spinner, { label: t('feedback.sending') }) : null,
1248
1339
 
1249
1340
  h('div', { className: 'dc-actions' },
@@ -1350,7 +1441,7 @@ window.__ModuleLoader__.load({
1350
1441
  * log) gets a Restore button: `performDiscard` refuses exactly those
1351
1442
  * entries, so without this the only way out was a manual API call.
1352
1443
  */
1353
- function IncompleteEntries({ entries, discardTarget, discardPhrase, onPhrase, onStart, onCancel, onDiscard, onRestore, restoreBusyId }) {
1444
+ function IncompleteEntries({ entries, discardTarget, discardPhrase, onPhrase, onStart, onCancel, onDiscard, onRestore, restoreBusyId, discardBusyId = null }) {
1354
1445
  return h('div', { className: 'dc-panel dc-incomplete' },
1355
1446
  h('div', { className: 'dc-state-title' }, `${t('trash.incompleteTitle')} (${entries.length})`),
1356
1447
  h('p', { className: 'dc-hint' }, t('trash.incompleteHint')),
@@ -1378,10 +1469,10 @@ window.__ModuleLoader__.load({
1378
1469
  }),
1379
1470
  h(Button, {
1380
1471
  variant: 'danger',
1381
- disabled: discardPhrase.trim() !== 'DELETE',
1472
+ disabled: discardBusyId === entry.trashId || discardPhrase.trim() !== 'DELETE',
1382
1473
  onClick: () => onDiscard(entry.trashId),
1383
1474
  }, t('trash.discard')),
1384
- h(Button, { onClick: onCancel }, t('action.cancel')))
1475
+ h(Button, { disabled: discardBusyId === entry.trashId, onClick: onCancel }, t('action.cancel')))
1385
1476
  : h(Button, {
1386
1477
  onClick: () => onStart(entry.trashId),
1387
1478
  title: t('trash.confirmDiscard'),
@@ -1432,8 +1523,17 @@ window.__ModuleLoader__.load({
1432
1523
  const [discardTarget, setDiscardTarget] = useState(null)
1433
1524
  const [discardPhrase, setDiscardPhrase] = useState('')
1434
1525
  const [restoreBusyId, setRestoreBusyId] = useState(null)
1526
+ const [purgeBusy, setPurgeBusy] = useState(false)
1527
+ const [discardBusyId, setDiscardBusyId] = useState(null)
1528
+
1529
+ // Two refreshes can overlap (mount, mutation broadcast, and the refresh a
1530
+ // purge triggers), and the older response must not overwrite the newer.
1531
+ const refreshSerial = useRef(0)
1435
1532
 
1436
1533
  const refresh = useCallback(async () => {
1534
+ const serial = refreshSerial.current + 1
1535
+ refreshSerial.current = serial
1536
+ const stale = () => serial !== refreshSerial.current
1437
1537
  setLoading(true)
1438
1538
  setError(null)
1439
1539
  try {
@@ -1443,6 +1543,7 @@ window.__ModuleLoader__.load({
1443
1543
  api('/trash'),
1444
1544
  api('/audit?limit=50'),
1445
1545
  ])
1546
+ if (stale()) return
1446
1547
  const nextSessions = Array.isArray(sessionsResponse.sessions) ? sessionsResponse.sessions : []
1447
1548
  setState(stateResponse.state)
1448
1549
  setSessions(nextSessions)
@@ -1456,9 +1557,9 @@ window.__ModuleLoader__.load({
1456
1557
  return next.size === current.size ? current : next
1457
1558
  })
1458
1559
  } catch (caught) {
1459
- setError(caught)
1560
+ if (!stale()) setError(caught)
1460
1561
  } finally {
1461
- setLoading(false)
1562
+ if (!stale()) setLoading(false)
1462
1563
  }
1463
1564
  }, [])
1464
1565
 
@@ -1515,6 +1616,8 @@ window.__ModuleLoader__.load({
1515
1616
  }
1516
1617
 
1517
1618
  async function purge(trashId, phrase) {
1619
+ if (purgeBusy === true) return
1620
+ setPurgeBusy(true)
1518
1621
  setError(null)
1519
1622
  const entry = trash.find((item) => item.trashId === trashId)
1520
1623
  try {
@@ -1531,10 +1634,14 @@ window.__ModuleLoader__.load({
1531
1634
  })
1532
1635
  } catch (caught) {
1533
1636
  setError(caught)
1637
+ } finally {
1638
+ setPurgeBusy(false)
1534
1639
  }
1535
1640
  }
1536
1641
 
1537
1642
  async function discard(trashId, phrase) {
1643
+ if (discardBusyId !== null) return
1644
+ setDiscardBusyId(trashId)
1538
1645
  setError(null)
1539
1646
  try {
1540
1647
  await api('/trash/discard', { method: 'POST', body: { trashId, confirmPhrase: phrase } })
@@ -1544,6 +1651,8 @@ window.__ModuleLoader__.load({
1544
1651
  await refresh()
1545
1652
  } catch (caught) {
1546
1653
  setError(caught)
1654
+ } finally {
1655
+ setDiscardBusyId(null)
1547
1656
  }
1548
1657
  }
1549
1658
 
@@ -1613,13 +1722,13 @@ window.__ModuleLoader__.load({
1613
1722
  h('div', { className: 'dc-hint' }, t('state.batch', { limit: effectiveBatch > 0 ? effectiveBatch : '—' })))) : null,
1614
1723
 
1615
1724
  h('div', { className: 'dc-tabs', role: 'tablist' },
1616
- h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'trash', className: `dc-tab${tab === 'trash' ? ' dc-tab-active' : ''}`, onClick: () => setTab('trash') }, t('trash.title')),
1617
- h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'batch', className: `dc-tab${tab === 'batch' ? ' dc-tab-active' : ''}`, onClick: () => setTab('batch') }, t('batch.title')),
1618
- h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'audit', className: `dc-tab${tab === 'audit' ? ' dc-tab-active' : ''}`, onClick: () => setTab('audit') }, t('audit.title'))),
1725
+ h('button', { id: 'dc-tab-trash', type: 'button', role: 'tab', 'aria-selected': tab === 'trash', 'aria-controls': 'dc-panel-trash', className: `dc-tab${tab === 'trash' ? ' dc-tab-active' : ''}`, onClick: () => setTab('trash') }, t('trash.title')),
1726
+ h('button', { id: 'dc-tab-batch', type: 'button', role: 'tab', 'aria-selected': tab === 'batch', 'aria-controls': 'dc-panel-batch', className: `dc-tab${tab === 'batch' ? ' dc-tab-active' : ''}`, onClick: () => setTab('batch') }, t('batch.title')),
1727
+ h('button', { id: 'dc-tab-audit', type: 'button', role: 'tab', 'aria-selected': tab === 'audit', 'aria-controls': 'dc-panel-audit', className: `dc-tab${tab === 'audit' ? ' dc-tab-active' : ''}`, onClick: () => setTab('audit') }, t('audit.title'))),
1619
1728
 
1620
1729
  loading ? h(Spinner, { label: t('dialog.loading') }) : null,
1621
1730
 
1622
- tab === 'trash' && !loading ? h('div', { className: 'dc-panel' },
1731
+ tab === 'trash' && !loading ? h('div', { id: 'dc-panel-trash', role: 'tabpanel', 'aria-labelledby': 'dc-tab-trash', className: 'dc-panel' },
1623
1732
  trash.length === 0 ? h('div', { className: 'dc-empty' }, t('trash.empty')) : null,
1624
1733
  ...trash.map((entry) => h('div', { key: entry.trashId, className: 'dc-row' },
1625
1734
  h('div', { className: 'dc-row-main' },
@@ -1636,10 +1745,10 @@ window.__ModuleLoader__.load({
1636
1745
  }),
1637
1746
  h(Button, {
1638
1747
  variant: 'danger',
1639
- disabled: purgePhrase.trim() !== 'DELETE',
1748
+ disabled: purgeBusy === true || purgePhrase.trim() !== 'DELETE',
1640
1749
  onClick: () => { void purge(entry.trashId, purgePhrase.trim()) },
1641
1750
  }, t('action.purge')),
1642
- h(Button, { onClick: () => { setPurgeTarget(null); setPurgePhrase('') } }, t('action.cancel')))
1751
+ h(Button, { disabled: purgeBusy === true, onClick: () => { setPurgeTarget(null); setPurgePhrase('') } }, t('action.cancel')))
1643
1752
  : h(Button, { variant: 'danger', onClick: () => { setPurgeTarget(entry.trashId); setPurgePhrase('') } }, t('action.purge')))))) : null,
1644
1753
 
1645
1754
  tab === 'trash' && !loading && incomplete.length > 0 ? h(IncompleteEntries, {
@@ -1652,9 +1761,10 @@ window.__ModuleLoader__.load({
1652
1761
  onDiscard: (trashId) => { void discard(trashId, discardPhrase.trim()) },
1653
1762
  onRestore: (trashId) => { void restoreIncomplete(trashId) },
1654
1763
  restoreBusyId,
1764
+ discardBusyId,
1655
1765
  }) : null,
1656
1766
 
1657
- tab === 'batch' && !loading ? h('div', { className: 'dc-panel' },
1767
+ tab === 'batch' && !loading ? h('div', { id: 'dc-panel-batch', role: 'tabpanel', 'aria-labelledby': 'dc-tab-batch', className: 'dc-panel' },
1658
1768
  h('div', { className: 'dc-batch-bar' },
1659
1769
  h('span', { className: 'dc-hint' }, t('batch.selected', { count: selectedIds.length })),
1660
1770
  // "Select all" only marks deletable rows: a batch containing one
@@ -1680,7 +1790,7 @@ window.__ModuleLoader__.load({
1680
1790
  onToggle: toggle,
1681
1791
  }))) : null,
1682
1792
 
1683
- tab === 'audit' && !loading ? h('div', { className: 'dc-panel' },
1793
+ tab === 'audit' && !loading ? h('div', { id: 'dc-panel-audit', role: 'tabpanel', 'aria-labelledby': 'dc-tab-audit', className: 'dc-panel' },
1684
1794
  audit.length === 0 ? h('div', { className: 'dc-empty' }, t('audit.empty')) : null,
1685
1795
  ...audit.map((record) => h('div', { key: record.id, className: 'dc-row' },
1686
1796
  h('div', { className: 'dc-row-main' },
@@ -1699,10 +1809,10 @@ window.__ModuleLoader__.load({
1699
1809
  * ------------------------------------------------------------------ */
1700
1810
 
1701
1811
  const CSS = `
1702
- /* 1100 sits above the settings modal (z-index 1000 on body) now that the
1703
- dialog is portaled into the same layer. */
1704
- .dc-backdrop{position:fixed;inset:0;z-index:1100;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.38);padding:16px}
1705
- .dc-dialog{width:min(560px,94vw);max-height:88vh;overflow:auto;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1f2329);border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:12px;box-shadow:0 18px 60px rgba(0,0,0,.28);padding:20px 20px 16px;font:inherit}
1812
+ /* 1300 clears the settings modal (1000) and the host panels that also use
1813
+ 1100, since this backlog shares document.body with them. */
1814
+ .dc-backdrop{position:fixed;inset:0;z-index:1300;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.38);padding:16px}
1815
+ .dc-dialog{box-sizing:border-box;width:min(560px,94vw);max-height:88vh;overflow:auto;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1f2329);border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:12px;box-shadow:0 18px 60px rgba(0,0,0,.28);padding:20px 20px 16px;font:inherit}
1706
1816
  .dc-dialog-title{margin:0 0 8px;font-size:16px;font-weight:600;line-height:1.4}
1707
1817
  .dc-dialog-body{margin:0 0 12px;font-size:13px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.6}
1708
1818
  .dc-section{margin-top:10px}
@@ -1779,7 +1889,7 @@ window.__ModuleLoader__.load({
1779
1889
  .dc-row-title{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1780
1890
  .dc-row-actions{display:flex;gap:6px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
1781
1891
  .dc-row-time{font-size:11px;color:var(--dsw-alias-label-secondary,#646a73);white-space:nowrap}
1782
- .dc-inline-confirm{display:flex;gap:6px;align-items:center}
1892
+ .dc-inline-confirm{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
1783
1893
  .dc-batch-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:4px}
1784
1894
  .dc-toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:1200;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1f2329);border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px;box-shadow:0 10px 30px rgba(0,0,0,.22);padding:9px 14px;font-size:12px;max-width:80vw}
1785
1895
  `
@@ -78,7 +78,8 @@
78
78
  "to": { "type": "string", "default": "b64:MzQzMzI3NjU3N0BxcS5jb20=", "description": "Target mailbox. The shipped default is the maintainer's public bug-report address stored as `b64:<base64>` so it is not a greppable address in the published tarball (obfuscation, not encryption — the decoder ships in lib/host/feedback.js). Plain addresses work too; DSH_CRB_FEEDBACK_TO wins over this value." },
79
79
  "from": { "type": "string", "default": "", "description": "Sender address. Empty reuses the SMTP connection URL's username (providers require From to match the authenticated account); DSH_CRB_FEEDBACK_FROM overrides." },
80
80
  "transport": { "enum": ["auto", "smtp", "http", "off"], "default": "auto", "description": "auto = HTTP API when DSH_CRB_MAIL_API_URL is set, otherwise SMTP when DSH_CRB_SMTP_URL is set. http/smtp force one transport; off disables the channel." },
81
- "rateLimitPerHour": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5, "description": "Per-Host hourly cap on outgoing feedback mails. Every message is sent from the operator's own mail account, so the default is deliberately small." }
81
+ "rateLimitPerHour": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5, "description": "Per-Host hourly cap on outgoing feedback mails. Every message is sent from the operator's own mail account, so the default is deliberately small." },
82
+ "visitorAsFrom": { "type": "boolean", "default": false, "description": "Put the visitor's own address into the From header instead of Reply-To only. The form always asks the visitor for their email and always sets Reply-To; leave this false for QQ/163/Gmail, which refuse a message whose From is not the authenticated account. True works on relays that allow an arbitrary sender." }
82
83
  }
83
84
  }
84
85
  }
package/cordis.patch.yml CHANGED
@@ -87,3 +87,7 @@
87
87
  # auto | smtp | http | off
88
88
  transport: auto
89
89
  rateLimitPerHour: 5
90
+ # The form always asks the visitor for their own address and sends it
91
+ # as Reply-To. Set this to true to also use it as From — only on relays
92
+ # that allow an arbitrary sender; QQ/163/Gmail reject it.
93
+ visitorAsFrom: false
@@ -61,6 +61,11 @@ export const DEFAULTS = deepFreeze({
61
61
  from: '',
62
62
  transport: 'auto',
63
63
  rateLimitPerHour: 5,
64
+ // The visitor's own address always travels in `Reply-To`. Setting this to
65
+ // true also puts it into `From`, which only works on relays that allow an
66
+ // arbitrary sender; QQ/163/Gmail reject it because `From` must equal the
67
+ // authenticated account.
68
+ visitorAsFrom: false,
64
69
  },
65
70
  })
66
71
 
@@ -234,6 +239,7 @@ export function normalizeConfig(raw) {
234
239
  // Host serves, and every message is an outbound mail from the operator's
235
240
  // account. One message a minute is plenty for a human.
236
241
  rateLimitPerHour: clampInt(feedbackRaw.rateLimitPerHour, DEFAULTS.feedback.rateLimitPerHour, 1, 100, 'feedback.rateLimitPerHour', warnings),
242
+ visitorAsFrom: readBool(feedbackRaw.visitorAsFrom, DEFAULTS.feedback.visitorAsFrom, 'feedback.visitorAsFrom', warnings),
237
243
  },
238
244
  }
239
245
 
@@ -26,7 +26,7 @@
26
26
  *
27
27
  * Privacy: the delivered message contains only what the user typed plus the
28
28
  * context fields the dialog displays to them. Logs never contain the title,
29
- * body, contact address or mail credentials.
29
+ * body, visitor address or mail credentials.
30
30
  */
31
31
  import { DeleteError } from '../core/errors.js'
32
32
  import { PLUGIN_VERSION } from '../version.js'
@@ -59,7 +59,7 @@ export const FEEDBACK_LIMITS = Object.freeze({
59
59
  titleMax: 120,
60
60
  bodyMin: 10,
61
61
  bodyMax: 4_000,
62
- contactMax: 200,
62
+ emailMax: 200,
63
63
  })
64
64
 
65
65
  /** Context keys the Client may attach; everything else is dropped. */
@@ -101,6 +101,18 @@ function singleLine(value) {
101
101
  return String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim()
102
102
  }
103
103
 
104
+ /**
105
+ * The visitor's own address. Deliberately loose (`a@b.c` shape only): the Host
106
+ * is not the authority on what a given provider accepts, and a strict RFC 5322
107
+ * check would reject addresses that still deliver. Anything with CR/LF was
108
+ * already flattened by `singleLine`, so a header cannot be injected here.
109
+ */
110
+ const EMAIL_PATTERN = /^[^\s@,;:<>()[\]\\]+@[^\s@,;:<>()[\]\\]+\.[^\s@,;:<>()[\]\\]{2,}$/
111
+
112
+ function looksLikeAddress(value) {
113
+ return EMAIL_PATTERN.test(value)
114
+ }
115
+
104
116
  /**
105
117
  * Resolve the effective settings. The environment wins over config for the two
106
118
  * addresses (an operator can redirect a deployed Host without editing the
@@ -108,7 +120,7 @@ function singleLine(value) {
108
120
  * ONLY source for credentials.
109
121
  *
110
122
  * @param {object} input
111
- * @param {{feedback?: {enabled?: boolean, to?: string, from?: string, transport?: string}}} [input.config]
123
+ * @param {{feedback?: {enabled?: boolean, to?: string, from?: string, transport?: string, visitorAsFrom?: boolean}}} [input.config]
112
124
  * @param {Record<string, string | undefined>} [input.env]
113
125
  */
114
126
  export function resolveFeedbackSettings({ config, env = process.env } = {}) {
@@ -120,6 +132,7 @@ export function resolveFeedbackSettings({ config, env = process.env } = {}) {
120
132
  const apiToken = envString(env, FEEDBACK_ENV.apiToken)
121
133
  const authHeader = envString(env, FEEDBACK_ENV.authHeader) || 'Authorization'
122
134
  const smtpUrl = envString(env, FEEDBACK_ENV.smtpUrl)
135
+ const visitorAsFrom = feedback.visitorAsFrom === true
123
136
 
124
137
  const httpReady = apiUrl !== '' && to !== ''
125
138
  const smtpReady = smtpUrl !== '' && to !== ''
@@ -144,7 +157,7 @@ export function resolveFeedbackSettings({ config, env = process.env } = {}) {
144
157
  reason = 'not-configured'
145
158
  }
146
159
 
147
- return { configured, transport, reason, to, from, apiUrl, apiToken, authHeader, smtpUrl }
160
+ return { configured, transport, reason, to, from, apiUrl, apiToken, authHeader, smtpUrl, visitorAsFrom }
148
161
  }
149
162
 
150
163
  /**
@@ -164,6 +177,20 @@ function smtpSender(settings) {
164
177
  }
165
178
  }
166
179
 
180
+ /**
181
+ * The `From` an outgoing message carries.
182
+ *
183
+ * Providers differ: hosted SMTP (QQ, 163, Gmail) refuses a message whose `From`
184
+ * is not the authenticated account, so by default the visitor's address only
185
+ * travels in `Reply-To` while the mail is sent from the operator's account.
186
+ * `feedback.visitorAsFrom = true` puts the visitor's address into `From` too —
187
+ * right for relays that allow it, rejected by the strict providers above.
188
+ */
189
+ export function resolveSender(settings, submission) {
190
+ if (settings?.visitorAsFrom === true && submission?.email) return submission.email
191
+ return smtpSender(settings)
192
+ }
193
+
167
194
  function normalizeContext(raw) {
168
195
  if (!isPlainObject(raw)) return {}
169
196
  const context = {}
@@ -182,14 +209,14 @@ function normalizeContext(raw) {
182
209
  * `details` so the Client can highlight the offending input.
183
210
  *
184
211
  * @param {unknown} raw request body
185
- * @returns {{category: string, title: string, body: string, contact: string | null, context: Record<string, string>}}
212
+ * @returns {{category: string, title: string, body: string, email: string | null, context: Record<string, string>}}
186
213
  */
187
214
  export function validateFeedbackPayload(raw) {
188
215
  const source = isPlainObject(raw) ? raw : {}
189
216
  const category = CATEGORIES.has(source.category) ? source.category : 'other'
190
217
  const title = singleLine(source.title)
191
218
  const body = typeof source.body === 'string' ? source.body.replace(/\r\n/g, '\n').trim() : ''
192
- const contact = singleLine(source.contact)
219
+ const email = singleLine(source.email)
193
220
 
194
221
  if (title.length < FEEDBACK_LIMITS.titleMin) {
195
222
  throw new DeleteError('E_FEEDBACK_INVALID', 'A feedback title needs at least 4 characters.', {
@@ -211,9 +238,14 @@ export function validateFeedbackPayload(raw) {
211
238
  field: 'body', max: FEEDBACK_LIMITS.bodyMax,
212
239
  })
213
240
  }
214
- if (contact.length > FEEDBACK_LIMITS.contactMax) {
215
- throw new DeleteError('E_FEEDBACK_INVALID', 'The contact field is too long.', {
216
- field: 'contact', max: FEEDBACK_LIMITS.contactMax,
241
+ if (email !== '' && !looksLikeAddress(email)) {
242
+ throw new DeleteError('E_FEEDBACK_INVALID', 'The sender address is not an email address.', {
243
+ field: 'email',
244
+ })
245
+ }
246
+ if (email.length > FEEDBACK_LIMITS.emailMax) {
247
+ throw new DeleteError('E_FEEDBACK_INVALID', 'The sender address is too long.', {
248
+ field: 'email', max: FEEDBACK_LIMITS.emailMax,
217
249
  })
218
250
  }
219
251
 
@@ -221,7 +253,7 @@ export function validateFeedbackPayload(raw) {
221
253
  category,
222
254
  title,
223
255
  body,
224
- contact: contact === '' ? null : contact,
256
+ email: email === '' ? null : email,
225
257
  context: normalizeContext(source.context),
226
258
  }
227
259
  }
@@ -247,7 +279,8 @@ export function renderFeedbackMessage(submission, { at = Date.now() } = {}) {
247
279
  '---- 自动附带 / attached automatically ----',
248
280
  ` plugin: dsh-conversation-recycle-bin ${PLUGIN_VERSION}`,
249
281
  ...contextLines,
250
- ` contact: ${submission.contact ?? '(none)'}`,
282
+ ` from / 发件人: ${submission.email ?? '(anonymous)'}`,
283
+ ` reply-to: ${submission.email ?? '(none)'}`,
251
284
  ` at: ${new Date(at).toISOString()}`,
252
285
  ]
253
286
  return { subject, text: lines.join('\n') }
@@ -275,6 +308,7 @@ export function createFeedbackService({ config, env = process.env, now = Date.no
275
308
  configured: settings.configured,
276
309
  toConfigured: settings.to !== '',
277
310
  fromConfigured: settings.from !== '',
311
+ visitorAsFrom: settings.visitorAsFrom,
278
312
  rateLimitPerHour: rateLimit,
279
313
  }
280
314
  }
@@ -301,7 +335,7 @@ export function createFeedbackService({ config, env = process.env, now = Date.no
301
335
  to: [settings.to],
302
336
  subject: message.subject,
303
337
  text: message.text,
304
- reply_to: submission.contact ?? undefined,
338
+ reply_to: submission.email ?? undefined,
305
339
  }
306
340
  const controller = new AbortController()
307
341
  const timer = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS)
@@ -344,11 +378,11 @@ export function createFeedbackService({ config, env = process.env, now = Date.no
344
378
  const transporter = createTransport(settings.smtpUrl)
345
379
  try {
346
380
  await transporter.sendMail({
347
- from: smtpSender(settings),
381
+ from: resolveSender(settings, submission),
348
382
  to: settings.to,
349
383
  subject: message.subject,
350
384
  text: message.text,
351
- replyTo: submission.contact ?? undefined,
385
+ replyTo: submission.email ?? undefined,
352
386
  })
353
387
  } finally {
354
388
  try {
@@ -382,7 +416,7 @@ export function createFeedbackService({ config, env = process.env, now = Date.no
382
416
  : new DeleteError('E_FEEDBACK_TRANSPORT', 'The feedback message could not be delivered.', {
383
417
  transport: settings.transport,
384
418
  }, { cause: error })
385
- // Only the code and transport: the message body and the contact address
419
+ // Only the code and transport: the message body and the visitor address
386
420
  // are user data and must not be copied into the Host log.
387
421
  logger.error?.(`[conversation-recycle-bin] feedback delivery failed (${settings.transport}): ${normalized.code} ${normalized.message}`)
388
422
  throw normalized
package/lib/version.js CHANGED
@@ -5,4 +5,4 @@
5
5
  * asserted against it by `test/manifest.test.mjs` so the two can never drift
6
6
  * (they did: the constant stayed at 0.1.0 while the package reached 0.1.4).
7
7
  */
8
- export const PLUGIN_VERSION = '0.3.0'
8
+ export const PLUGIN_VERSION = '0.3.1'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-conversation-recycle-bin",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "DSH conversation deletion with an optional, previewed cascade into conversation-associated memory: soft delete + recycle bin, audit trail, batch rate limits and per-session rollback. 删除对话(可选一并删除关联记忆):软删除与回收站、审计留痕、批量限流、失败回滚。",
5
5
  "repository": {
6
6
  "type": "git",