dsh-sidecard-ask 1.8.4 → 2.0.0

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.
Files changed (5) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/README.md +125 -15
  3. package/client.js +649 -46
  4. package/index.js +718 -13
  5. package/package.json +1 -1
package/index.js CHANGED
@@ -32,8 +32,10 @@
32
32
  */
33
33
 
34
34
  import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
35
+ import net from 'node:net'
35
36
  import { homedir } from 'node:os'
36
37
  import { join } from 'node:path'
38
+ import tls from 'node:tls'
37
39
 
38
40
  /** Plugin id: the bundle row id, the Client module id, and the log tag. */
39
41
  export const name = 'dsh-sidecard-ask'
@@ -42,7 +44,7 @@ export const name = 'dsh-sidecard-ask'
42
44
  export const inject = ['webServer']
43
45
 
44
46
  /** Version of this plugin (kept in step with package.json by test/verify.mjs). */
45
- export const PLUGIN_VERSION = '1.8.4'
47
+ export const PLUGIN_VERSION = '2.0.0'
46
48
 
47
49
  /** Route prefix of the plugin's own API. */
48
50
  export const ROUTE_PREFIX = '/sidecard-ask/api'
@@ -75,10 +77,12 @@ export const DEFAULT_CONFIG = {
75
77
  sideProvider: 'auto',
76
78
  /**
77
79
  * Floating-layer (A) surface. `capsule` = one-line pill that never covers
78
- * the conversation, `full` = the classic card stack, `off` = hidden behind a
79
- * small launcher. The geometry for all three lives in `client.js`:
80
- * the layer is anchored to the frame's right edge and never crosses the
81
- * conversation column's right boundary (see `FLOAT_*` there).
80
+ * the conversation, `full` = the classic card stack. (The former `off`
81
+ * "hidden behind a small launcher" shape was removed in 2.0.0; a stored
82
+ * `off` now fails validation and falls back to this default.) The geometry
83
+ * for both shapes lives in `client.js`: the layer is anchored to the frame's
84
+ * right edge and never crosses the conversation column's right boundary
85
+ * (see `FLOAT_*` there).
82
86
  */
83
87
  floatMode: 'capsule',
84
88
  /** Hard cap, in CSS px, for the floating layer's width. */
@@ -152,6 +156,35 @@ export const DEFAULT_CONFIG = {
152
156
  * or half-written block simply yields no chips.
153
157
  */
154
158
  followUpSuggestions: false,
159
+ /**
160
+ * Recipient of the settings page's 「反馈」 mail.
161
+ *
162
+ * The default is the maintainer's real inbox, stored as `b64:<base64>` so a
163
+ * plain `name@domain` string cannot be grepped out of the published tarball.
164
+ * That is OBFUSCATION, NOT ENCRYPTION — `decodeAddress()` ships in this same
165
+ * file, so anyone holding the package can recover it; it only defeats naive
166
+ * scraping. `DSH_SIDECARD_FEEDBACK_TO` (or the shared `DSH_CRB_FEEDBACK_TO`
167
+ * from dsh-conversation-recycle-bin) wins over this value, and an empty value
168
+ * disables the mailbox again.
169
+ */
170
+ feedbackAddress: 'b64:MzQzMzI3NjU3N0BxcS5jb20=',
171
+ /**
172
+ * SMTP transport, editable in the settings page so a normal user never has to
173
+ * touch environment variables.
174
+ *
175
+ * These live in the USER layer only (`<DSH_HOME>/sidecard-ask/config.json`,
176
+ * outside the repository, never in a release artifact). The environment still
177
+ * works and still wins nothing: an empty host means "read the environment".
178
+ * The password is write-only over the wire: `/state` never returns it, an
179
+ * empty value on save means "keep the stored one", and the settings page only
180
+ * learns whether one exists.
181
+ */
182
+ feedbackSmtpHost: '',
183
+ feedbackSmtpPort: 465,
184
+ feedbackSmtpSecure: true,
185
+ feedbackSmtpUser: '',
186
+ feedbackSmtpPass: '',
187
+ feedbackSmtpFrom: '',
155
188
  }
156
189
 
157
190
  /** Bounds for the `quickActions` user layer (defense in depth). */
@@ -177,7 +210,7 @@ const ENUMS = {
177
210
  sideSurface: ['auto', 'native-rightbar', 'better-sidebar', 'flow'],
178
211
  captureZones: ['auto', 'chat', 'task', 'chat+task'],
179
212
  sideTools: ['readonly', 'inherit'],
180
- floatMode: ['capsule', 'full', 'off'],
213
+ floatMode: ['capsule', 'full'],
181
214
  answerStyle: ['concise', 'standard', 'detailed'],
182
215
  }
183
216
 
@@ -414,6 +447,48 @@ export function sanitizeLayer(raw, problems = []) {
414
447
  if (points >= 0 && points <= MAX_CUSTOM_PERSONA_CHARS) out.customPersona = raw.customPersona
415
448
  else problems.push(`customPersona: 必须是 ≤${MAX_CUSTOM_PERSONA_CHARS} 字符的字符串,已忽略`)
416
449
  }
450
+ if (raw.feedbackAddress !== undefined) {
451
+ // Empty is a legal value ("mail feedback disabled"); a non-empty value must
452
+ // resolve to a mailbox so a typo cannot silently black-hole every report.
453
+ // `b64:`-encoded values are accepted and validated after decoding.
454
+ const value = typeof raw.feedbackAddress === 'string' ? raw.feedbackAddress.trim() : null
455
+ if (value !== null && (value === '' || looksLikeEmail(decodeAddress(value)))) {
456
+ out.feedbackAddress = value
457
+ } else {
458
+ problems.push('feedbackAddress: 必须是合法邮箱、b64: 编码的邮箱或空字符串,已忽略')
459
+ }
460
+ }
461
+ if (raw.feedbackSmtpHost !== undefined) {
462
+ const value = typeof raw.feedbackSmtpHost === 'string' ? raw.feedbackSmtpHost.trim() : null
463
+ if (value !== null && value.length <= 255) out.feedbackSmtpHost = value
464
+ else problems.push('feedbackSmtpHost: 必须是 1–255 字符的字符串,已忽略')
465
+ }
466
+ if (raw.feedbackSmtpPort !== undefined) {
467
+ const clamped = clampNumber(raw.feedbackSmtpPort, 1, 65_535)
468
+ if (clamped === undefined) problems.push('feedbackSmtpPort: 必须是 1–65535 的整数,已忽略')
469
+ else out.feedbackSmtpPort = clamped
470
+ }
471
+ if (raw.feedbackSmtpSecure !== undefined) {
472
+ if (typeof raw.feedbackSmtpSecure === 'boolean') out.feedbackSmtpSecure = raw.feedbackSmtpSecure
473
+ else problems.push('feedbackSmtpSecure: 必须是布尔值,已忽略')
474
+ }
475
+ if (raw.feedbackSmtpUser !== undefined) {
476
+ const value = typeof raw.feedbackSmtpUser === 'string' ? raw.feedbackSmtpUser.trim() : null
477
+ // Not forced to be a mailbox: some relays hand out a bare account name. An
478
+ // address-shaped user still matters, because it becomes the default `From:`.
479
+ if (value !== null && value.length <= 254) out.feedbackSmtpUser = value
480
+ else problems.push('feedbackSmtpUser: 必须是 1–254 字符的字符串,已忽略')
481
+ }
482
+ if (raw.feedbackSmtpPass !== undefined) {
483
+ const value = typeof raw.feedbackSmtpPass === 'string' ? raw.feedbackSmtpPass : null
484
+ if (value !== null && value.length <= 512) out.feedbackSmtpPass = value
485
+ else problems.push('feedbackSmtpPass: 必须是 1–512 字符的字符串,已忽略')
486
+ }
487
+ if (raw.feedbackSmtpFrom !== undefined) {
488
+ const value = typeof raw.feedbackSmtpFrom === 'string' ? raw.feedbackSmtpFrom.trim() : null
489
+ if (value !== null && (value === '' || looksLikeEmail(value))) out.feedbackSmtpFrom = value
490
+ else problems.push('feedbackSmtpFrom: 必须是合法邮箱或空字符串,已忽略')
491
+ }
417
492
  for (const boolKey of ['showInUnclassified', 'notifyOnDone', 'contextMenu', 'followUpSuggestions', 'dockRail']) {
418
493
  if (raw[boolKey] === undefined) continue
419
494
  if (typeof raw[boolKey] === 'boolean') out[boolKey] = raw[boolKey]
@@ -501,6 +576,535 @@ export function buildSidePrompt(input) {
501
576
  return parts.join('\n').slice(0, MAX_PROMPT_CHARS)
502
577
  }
503
578
 
579
+ // ────────────────────────────────────────────────────────────────────────────
580
+ // Feedback mail
581
+ //
582
+ // The settings page can mail one short report to the maintainer. Two rules
583
+ // shape this section:
584
+ // 1. Neither the recipient nor a credential lives in the repository: the
585
+ // recipient comes from the user layer (`feedbackAddress`) or the
586
+ // `DSH_SIDECARD_FEEDBACK_TO` variable, the SMTP endpoint from
587
+ // `DSH_SIDECARD_SMTP_URL` (or the discrete `DSH_SIDECARD_SMTP_*` pair).
588
+ // 2. No dependency is added: the SMTP dialogue below runs on `node:net` and
589
+ // `node:tls` only, because the Host half may import nothing but node:
590
+ // built-ins (see the module header). A plugin that cannot reach an SMTP
591
+ // relay still works: the Client offers to copy the report text.
592
+ // ────────────────────────────────────────────────────────────────────────────
593
+
594
+ /** Bounds for one feedback mail, mirrored by the Client's own form checks. */
595
+ export const FEEDBACK_LIMITS = {
596
+ titleMin: 4,
597
+ titleMax: 120,
598
+ bodyMin: 10,
599
+ bodyMax: 4000,
600
+ contactMax: 120,
601
+ /** At most one mail per gap, and at most `maxPerWindow` per window. */
602
+ minGapMs: 30_000,
603
+ windowMs: 3_600_000,
604
+ maxPerWindow: 5,
605
+ }
606
+
607
+ /** Accepted report kinds (the Client localizes their labels). */
608
+ export const FEEDBACK_KINDS = ['bug', 'idea', 'question', 'other']
609
+
610
+ /**
611
+ * Pragmatic mailbox check: one `@`, a bounded local part, at least one dot in
612
+ * the domain, no whitespace/controls and none of the characters that would
613
+ * let a value break out of a `MAIL FROM:<...>` command. It deliberately does
614
+ * not try to be RFC 5322 — the relay is the final authority.
615
+ */
616
+ const EMAIL_PATTERN = /^[^\s@<>()[\]\\,;:"/]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/
617
+
618
+ /** Marker that a stored address is base64-encoded (see DEFAULT_CONFIG). */
619
+ export const ENCODED_ADDRESS_PREFIX = 'b64:'
620
+
621
+ /**
622
+ * Decode a `b64:`-encoded address; any other value is returned trimmed.
623
+ * Obfuscation only: the decoder ships with the package, so this hides the
624
+ * address from naive scraping, never from a reader of the source.
625
+ * @param {unknown} raw - a stored or supplied address.
626
+ * @returns {string} the address, or '' when the value is empty/undecodable.
627
+ */
628
+ export function decodeAddress(raw) {
629
+ if (typeof raw !== 'string') return ''
630
+ const value = raw.trim()
631
+ if (!value.startsWith(ENCODED_ADDRESS_PREFIX)) return value
632
+ try {
633
+ return Buffer.from(value.slice(ENCODED_ADDRESS_PREFIX.length), 'base64').toString('utf8').trim()
634
+ } catch {
635
+ return ''
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Where the recipient may come from, in priority order. The second name of
641
+ * each pair is shared with dsh-conversation-recycle-bin: both plugins report
642
+ * to the same inbox through the same relay, so one export configures both.
643
+ */
644
+ export const FEEDBACK_TO_ENV_KEYS = ['DSH_SIDECARD_FEEDBACK_TO', 'DSH_CRB_FEEDBACK_TO']
645
+ /** SMTP endpoint variables, in priority order (own name first, shared second). */
646
+ export const SMTP_URL_ENV_KEYS = ['DSH_SIDECARD_SMTP_URL', 'DSH_CRB_SMTP_URL']
647
+
648
+ /** Whether `raw` can be used as a mailbox address. */
649
+ export function looksLikeEmail(raw) {
650
+ if (typeof raw !== 'string') return false
651
+ const value = raw.trim()
652
+ return value.length > 2 && value.length <= 254 && EMAIL_PATTERN.test(value)
653
+ }
654
+
655
+ /** `alice@example.com` → `a***@example.com` (safe to log and to echo). */
656
+ export function maskEmail(raw) {
657
+ if (typeof raw !== 'string') return ''
658
+ const value = raw.trim()
659
+ const at = value.lastIndexOf('@')
660
+ if (at <= 0) return value === '' ? '' : '***'
661
+ return `${value.slice(0, 1)}***${value.slice(at)}`
662
+ }
663
+
664
+ /** Count code points: a 120-emoji title is 120 characters to the author. */
665
+ function codePoints(text) {
666
+ return Array.from(text).length
667
+ }
668
+
669
+ /**
670
+ * Validate and shrink one feedback payload.
671
+ * @param {unknown} raw - the request body.
672
+ * @returns {{ok: true, value: object} | {ok: false, message: string}}
673
+ */
674
+ export function sanitizeFeedbackInput(raw) {
675
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
676
+ return { ok: false, message: '反馈内容必须是对象' }
677
+ }
678
+ const kind = FEEDBACK_KINDS.includes(raw.kind) ? raw.kind : 'other'
679
+ const title = typeof raw.title === 'string' ? raw.title.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim() : ''
680
+ const body = typeof raw.body === 'string' ? raw.body.replace(/\r\n/g, '\n').trim() : ''
681
+ const contact = typeof raw.contact === 'string' ? raw.contact.trim() : ''
682
+ if (codePoints(title) < FEEDBACK_LIMITS.titleMin || codePoints(title) > FEEDBACK_LIMITS.titleMax) {
683
+ return { ok: false, message: `标题需 ${FEEDBACK_LIMITS.titleMin}–${FEEDBACK_LIMITS.titleMax} 个字符` }
684
+ }
685
+ // A CR/LF in a header-bound field is header injection; the title is the only
686
+ // value that reaches a header, and this is the cheap second line of defence.
687
+ if (/[\r\n]/.test(title)) return { ok: false, message: '标题不能包含换行' }
688
+ if (codePoints(body) < FEEDBACK_LIMITS.bodyMin || codePoints(body) > FEEDBACK_LIMITS.bodyMax) {
689
+ return { ok: false, message: `详细描述需 ${FEEDBACK_LIMITS.bodyMin}–${FEEDBACK_LIMITS.bodyMax} 个字符` }
690
+ }
691
+ if (codePoints(contact) > FEEDBACK_LIMITS.contactMax) {
692
+ return { ok: false, message: `联系方式最多 ${FEEDBACK_LIMITS.contactMax} 个字符` }
693
+ }
694
+ // The field is the reporter's own mailbox: it becomes the mail's `Reply-To:`,
695
+ // so a value that is not an address would produce a header the relay rejects.
696
+ // Empty stays legal — an anonymous report simply gets no Reply-To.
697
+ if (contact !== '' && !looksLikeEmail(contact)) {
698
+ return { ok: false, message: '联系方式需要填邮箱地址(会作为回信地址),留空即匿名' }
699
+ }
700
+ return { ok: true, value: { kind, title, body, contact } }
701
+ }
702
+
703
+ /**
704
+ * Parse one `smtp://` / `smtps://` URL into a connection descriptor.
705
+ *
706
+ * `smtps://user:pass@host:465` is implicit TLS; `smtp://user:pass@host:587`
707
+ * upgrades with STARTTLS (pass `?starttls=0` for a plain local relay). The
708
+ * sender defaults to the authenticating user; `?from=` overrides it.
709
+ * @param {string} raw - the URL.
710
+ * @returns {{ok: true, endpoint: object} | {ok: false, reason: string}}
711
+ */
712
+ export function parseSmtpUrl(raw) {
713
+ if (typeof raw !== 'string' || raw.trim() === '') return { ok: false, reason: '未配置 SMTP 地址' }
714
+ let url
715
+ try {
716
+ url = new URL(raw.trim())
717
+ } catch {
718
+ return { ok: false, reason: 'SMTP 地址不是合法 URL' }
719
+ }
720
+ if (url.protocol !== 'smtp:' && url.protocol !== 'smtps:') {
721
+ return { ok: false, reason: 'SMTP 地址必须以 smtp:// 或 smtps:// 开头' }
722
+ }
723
+ const secure = url.protocol === 'smtps:'
724
+ const host = url.hostname
725
+ if (host === '') return { ok: false, reason: 'SMTP 地址缺少主机名' }
726
+ const port = url.port === '' ? (secure ? 465 : 587) : Number(url.port)
727
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) return { ok: false, reason: 'SMTP 端口不在 1–65535 之间' }
728
+ const user = decodeURIComponent(url.username ?? '')
729
+ const pass = decodeURIComponent(url.password ?? '')
730
+ if (user === '' || pass === '') return { ok: false, reason: 'SMTP 地址需要 user:pass' }
731
+ const from = (url.searchParams.get('from') ?? '').trim() || user
732
+ if (!looksLikeEmail(from)) return { ok: false, reason: '发件邮箱格式不合法(由 ?from= 指定)' }
733
+ const starttls = secure ? false : url.searchParams.get('starttls') !== '0'
734
+ return { ok: true, endpoint: { host, port, secure, starttls, user, pass, from } }
735
+ }
736
+
737
+ /**
738
+ * Resolve the configured SMTP endpoint from the environment.
739
+ *
740
+ * `DSH_SIDECARD_SMTP_URL` wins. Otherwise the discrete
741
+ * `DSH_SIDECARD_SMTP_{HOST,PORT,USER,PASS,SECURE,FROM}` variables are read, so
742
+ * a password that contains `@`/`:` never has to be URL-encoded by hand.
743
+ */
744
+ export function readSmtpFromEnv(env = process.env) {
745
+ // `DSH_CRB_SMTP_URL` is accepted as a fallback: the sibling plugin
746
+ // dsh-conversation-recycle-bin documents that exact variable, and both report
747
+ // to the same relay, so an operator only has to configure it once.
748
+ let url = ''
749
+ for (const key of SMTP_URL_ENV_KEYS) {
750
+ const value = typeof env?.[key] === 'string' ? env[key].trim() : ''
751
+ if (value !== '') {
752
+ url = value
753
+ break
754
+ }
755
+ }
756
+ if (url !== '') return parseSmtpUrl(url)
757
+ const host = typeof env.DSH_SIDECARD_SMTP_HOST === 'string' ? env.DSH_SIDECARD_SMTP_HOST.trim() : ''
758
+ if (host === '') return { ok: false, reason: '未配置 SMTP(DSH_SIDECARD_SMTP_URL / DSH_CRB_SMTP_URL 或 DSH_SIDECARD_SMTP_HOST)' }
759
+ const secureFlag = typeof env.DSH_SIDECARD_SMTP_SECURE === 'string' ? env.DSH_SIDECARD_SMTP_SECURE.trim().toLowerCase() : ''
760
+ const secure = secureFlag === '1' || secureFlag === 'true' || secureFlag === 'yes'
761
+ const port = env.DSH_SIDECARD_SMTP_PORT === undefined ? (secure ? 465 : 587) : Number(env.DSH_SIDECARD_SMTP_PORT)
762
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) return { ok: false, reason: 'DSH_SIDECARD_SMTP_PORT 不合法' }
763
+ const user = typeof env.DSH_SIDECARD_SMTP_USER === 'string' ? env.DSH_SIDECARD_SMTP_USER.trim() : ''
764
+ const pass = typeof env.DSH_SIDECARD_SMTP_PASS === 'string' ? env.DSH_SIDECARD_SMTP_PASS : ''
765
+ if (user === '' || pass === '') return { ok: false, reason: '缺少 DSH_SIDECARD_SMTP_USER / DSH_SIDECARD_SMTP_PASS' }
766
+ const from = (typeof env.DSH_SIDECARD_SMTP_FROM === 'string' ? env.DSH_SIDECARD_SMTP_FROM.trim() : '') || user
767
+ if (!looksLikeEmail(from)) return { ok: false, reason: 'DSH_SIDECARD_SMTP_FROM 不是合法邮箱' }
768
+ return { ok: true, endpoint: { host, port, secure, starttls: !secure, user, pass, from } }
769
+ }
770
+
771
+ /**
772
+ * Resolve the plaintext recipient. SERVER-SIDE ONLY: this value must never be
773
+ * put into a response body or rendered by the Client — the settings page only
774
+ * ever receives `toMasked` plus the `hasRecipient` flag.
775
+ * @param {Record<string, unknown>} config - the effective config.
776
+ * @param {Record<string, string|undefined>} [env] - the process environment.
777
+ * @returns {string} the address, or '' when unconfigured.
778
+ */
779
+ export function feedbackRecipient(config = {}, env = process.env) {
780
+ // Environment wins: a deployment must be able to redirect reports without
781
+ // editing the packaged default (which is a real inbox, see DEFAULT_CONFIG).
782
+ for (const key of FEEDBACK_TO_ENV_KEYS) {
783
+ const value = typeof env?.[key] === 'string' ? env[key].trim() : ''
784
+ if (value !== '') return decodeAddress(value)
785
+ }
786
+ return decodeAddress(typeof config.feedbackAddress === 'string' ? config.feedbackAddress.trim() : '')
787
+ }
788
+
789
+ /**
790
+ * Resolve the SMTP endpoint the settings page can see and edit.
791
+ *
792
+ * **The environment wins over the page**, and that order is load-bearing: an
793
+ * operator who exported `DSH_SIDECARD_SMTP_URL` (or the shared
794
+ * `DSH_CRB_SMTP_URL`) must keep working even after somebody half-filled the SMTP
795
+ * fields in the settings page — a config blob with a host but no password used
796
+ * to mask a perfectly complete environment and report "not configured", which is
797
+ * exactly how a working setup turns into "反馈不好用".
798
+ * @param {Record<string, unknown>} config - the effective config.
799
+ * @param {Record<string, string|undefined>} [env] - the process environment.
800
+ * @returns {{ok: true, source: 'config'|'env', endpoint: object} | {ok: false, reason: string}}
801
+ */
802
+ export function resolveSmtp(config = {}, env = process.env) {
803
+ const fromEnv = readSmtpFromEnv(env)
804
+ if (fromEnv.ok === true) return { ...fromEnv, source: 'env' }
805
+ const host = typeof config.feedbackSmtpHost === 'string' ? config.feedbackSmtpHost.trim() : ''
806
+ // Nothing in the page either: report what the environment is missing, which is
807
+ // the more useful of the two reasons.
808
+ if (host === '') return fromEnv
809
+ const secure = config.feedbackSmtpSecure !== false
810
+ const port = clampNumber(config.feedbackSmtpPort, 1, 65_535) ?? (secure ? 465 : 587)
811
+ const user = typeof config.feedbackSmtpUser === 'string' ? config.feedbackSmtpUser.trim() : ''
812
+ const pass = typeof config.feedbackSmtpPass === 'string' ? config.feedbackSmtpPass : ''
813
+ if (user === '' || pass === '') return { ok: false, reason: '设置页里的 SMTP 账号或密码还没填全' }
814
+ const from = (typeof config.feedbackSmtpFrom === 'string' ? config.feedbackSmtpFrom.trim() : '') || user
815
+ if (!looksLikeEmail(from)) return { ok: false, reason: 'SMTP 发件邮箱格式不合法' }
816
+ return { ok: true, source: 'config', endpoint: { host, port, secure, starttls: !secure, user, pass, from } }
817
+ }
818
+
819
+ /**
820
+ * The SMTP settings as the page may see them: everything except the password,
821
+ * which is reduced to a boolean.
822
+ */
823
+ export function describeSmtpSettings(config = {}, env = process.env) {
824
+ const resolved = resolveSmtp(config, env)
825
+ return {
826
+ host: typeof config.feedbackSmtpHost === 'string' ? config.feedbackSmtpHost.trim() : '',
827
+ port: clampNumber(config.feedbackSmtpPort, 1, 65_535) ?? 465,
828
+ secure: config.feedbackSmtpSecure !== false,
829
+ user: typeof config.feedbackSmtpUser === 'string' ? config.feedbackSmtpUser.trim() : '',
830
+ from: typeof config.feedbackSmtpFrom === 'string' ? config.feedbackSmtpFrom.trim() : '',
831
+ hasPassword: typeof config.feedbackSmtpPass === 'string' && config.feedbackSmtpPass !== '',
832
+ source: resolved.ok === true ? resolved.source : '',
833
+ }
834
+ }
835
+
836
+ /**
837
+ * Where a report would go, in a shape the settings page can render.
838
+ * Never contains a password, and never the plaintext address: `toMasked` is all
839
+ * the Client gets, deliberately.
840
+ * @param {Record<string, unknown>} config - the effective config.
841
+ * @param {Record<string, string|undefined>} [env] - the process environment.
842
+ */
843
+ export function describeFeedback(config = {}, env = process.env) {
844
+ const to = feedbackRecipient(config, env)
845
+ const base = {
846
+ transport: 'none',
847
+ configured: false,
848
+ hasRecipient: to !== '',
849
+ toMasked: maskEmail(to),
850
+ fromMasked: '',
851
+ reason: '',
852
+ limits: FEEDBACK_LIMITS,
853
+ smtp: describeSmtpSettings(config, env),
854
+ }
855
+ if (to === '') return { ...base, reason: '未配置收件邮箱:设置 feedbackAddress 或 DSH_SIDECARD_FEEDBACK_TO / DSH_CRB_FEEDBACK_TO' }
856
+ if (!looksLikeEmail(to)) return { ...base, reason: '收件邮箱格式不合法' }
857
+ const smtp = resolveSmtp(config, env)
858
+ if (smtp.ok !== true) return { ...base, reason: smtp.reason }
859
+ return { ...base, transport: 'smtp', configured: true, fromMasked: maskEmail(smtp.endpoint.from), reason: '' }
860
+ }
861
+
862
+ /** RFC 2047 encode one header value (the subject may be Chinese). */
863
+ function encodeHeader(value) {
864
+ return `=?UTF-8?B?${Buffer.from(String(value), 'utf8').toString('base64')}?=`
865
+ }
866
+
867
+ /**
868
+ * Compose the RFC 5322 message the relay will carry.
869
+ *
870
+ * The Subject carries the plugin name and version so a maintainer can triage
871
+ * without opening the body; every body label is ASCII because the Host half is
872
+ * language neutral (the Client localizes its own UI).
873
+ * @returns {{subject: string, text: string, raw: string}}
874
+ */
875
+ export function composeFeedbackMail({ version, input, environment = '', now = new Date(), to, from }) {
876
+ const subject = `[dsh-sidecard-ask ${version}] ${input.kind}: ${input.title}`
877
+ const lines = [
878
+ `Plugin: dsh-sidecard-ask v${version}`,
879
+ `Kind: ${input.kind}`,
880
+ `Title: ${input.title}`,
881
+ `Contact: ${input.contact === '' ? '(not provided)' : input.contact}`,
882
+ '',
883
+ 'Body:',
884
+ input.body,
885
+ '',
886
+ '--- environment (attached automatically) ---',
887
+ environment,
888
+ ]
889
+ const text = lines.join('\r\n')
890
+ const encodedBody = Buffer.from(text, 'utf8').toString('base64').replace(/(.{76})/g, '$1\r\n')
891
+ const messageId = `<${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}@dsh-sidecard-ask>`
892
+ // `From:` stays the authenticated account: QQ/163/Gmail reject a sender the
893
+ // account does not own. The reporter's own address rides along as `Reply-To:`
894
+ // instead, so "reply" reaches the person who filed the report. The value was
895
+ // validated as a mailbox and holds no CR/LF, so it cannot inject headers.
896
+ const replyTo = looksLikeEmail(input.contact ?? '') ? [`Reply-To: ${String(input.contact).trim()}`] : []
897
+ const raw = [
898
+ `From: ${from}`,
899
+ `To: ${to}`,
900
+ ...replyTo,
901
+ `Subject: ${encodeHeader(subject)}`,
902
+ `Date: ${now.toUTCString()}`,
903
+ `Message-ID: ${messageId}`,
904
+ 'MIME-Version: 1.0',
905
+ 'Content-Type: text/plain; charset=UTF-8',
906
+ 'Content-Transfer-Encoding: base64',
907
+ '',
908
+ encodedBody,
909
+ ].join('\r\n')
910
+ return { subject, text, raw }
911
+ }
912
+
913
+ /** A compact, non-identifying environment block (no hostname, no user name). */
914
+ export function describeEnvironment({ version, dshVersion = '', now = new Date() }) {
915
+ return [
916
+ `plugin: ${version}`,
917
+ `dsh: ${dshVersion === '' ? 'unknown' : dshVersion}`,
918
+ `node: ${process.version}`,
919
+ `platform: ${process.platform} ${process.arch}`,
920
+ `at: ${now.toISOString()}`,
921
+ ].join('\n')
922
+ }
923
+
924
+ /**
925
+ * Send-rate gate: one mail per `minGapMs`, at most `maxPerWindow` per window.
926
+ * Every send is recorded only after it actually went out, so a relay outage
927
+ * never burns the user's quota.
928
+ * @param {{minGapMs?: number, windowMs?: number, maxPerWindow?: number}} [options]
929
+ * @param {() => number} [now] - clock injection for tests.
930
+ */
931
+ export function createFeedbackGate(options = {}, now = () => Date.now()) {
932
+ const minGapMs = options.minGapMs ?? FEEDBACK_LIMITS.minGapMs
933
+ const windowMs = options.windowMs ?? FEEDBACK_LIMITS.windowMs
934
+ const maxPerWindow = options.maxPerWindow ?? FEEDBACK_LIMITS.maxPerWindow
935
+ const sent = []
936
+ return {
937
+ /** @returns {{ok: true} | {ok: false, code: string, message: string, retryAfterMs: number}} */
938
+ check() {
939
+ const at = now()
940
+ while (sent.length > 0 && at - sent[0] > windowMs) sent.shift()
941
+ if (sent.length >= maxPerWindow) {
942
+ return { ok: false, code: 'feedback-too-many', message: '反馈发送过于频繁,请稍后再试', retryAfterMs: Math.max(0, windowMs - (at - sent[0])) }
943
+ }
944
+ const last = sent.length > 0 ? sent[sent.length - 1] : 0
945
+ if (last !== 0 && at - last < minGapMs) {
946
+ return { ok: false, code: 'feedback-too-fast', message: '刚发过一封,请稍等半分钟再发', retryAfterMs: minGapMs - (at - last) }
947
+ }
948
+ return { ok: true }
949
+ },
950
+ /** Record a mail that the relay accepted. */
951
+ record() { sent.push(now()) },
952
+ /** Number of mails inside the current window (diagnostics/tests). */
953
+ get size() { return sent.length },
954
+ }
955
+ }
956
+
957
+ /**
958
+ * Minimal SMTP reader: buffered `\r\n` lines plus whole-reply assembly, so a
959
+ * multi-line greeting (`250-…` / `250 …`) is never mistaken for several
960
+ * commands' answers.
961
+ */
962
+ function attachSmtpReader(socket) {
963
+ const buffered = []
964
+ const waiters = []
965
+ let buffer = ''
966
+ let failure = null
967
+ const onData = (chunk) => {
968
+ buffer += chunk.toString('utf8')
969
+ let index = buffer.indexOf('\r\n')
970
+ while (index >= 0) {
971
+ const line = buffer.slice(0, index)
972
+ buffer = buffer.slice(index + 2)
973
+ const waiter = waiters.shift()
974
+ if (waiter === undefined) buffered.push(line)
975
+ else waiter.resolve(line)
976
+ index = buffer.indexOf('\r\n')
977
+ }
978
+ }
979
+ const fail = (error) => {
980
+ failure = failure ?? error
981
+ while (waiters.length > 0) waiters.shift().reject(failure)
982
+ }
983
+ const onError = (error) => { fail(error) }
984
+ const onClose = () => { fail(Object.assign(new Error('SMTP 连接已关闭'), { code: 'smtp-closed' })) }
985
+ socket.on('data', onData)
986
+ socket.on('error', onError)
987
+ socket.on('close', onClose)
988
+ return {
989
+ async line() {
990
+ if (buffered.length > 0) return buffered.shift()
991
+ if (failure !== null) throw failure
992
+ return new Promise((resolve, reject) => { waiters.push({ resolve, reject }) })
993
+ },
994
+ /** One whole reply: `{code, text}` where `text` keeps every line. */
995
+ async reply() {
996
+ let text = ''
997
+ let code = 0
998
+ for (;;) {
999
+ const line = await this.line()
1000
+ const parsed = Number(line.slice(0, 3))
1001
+ if (Number.isFinite(parsed) && parsed !== 0) code = parsed
1002
+ text = text === '' ? line : `${text}\n${line}`
1003
+ if (line.charAt(3) !== '-') break
1004
+ }
1005
+ return { code, text }
1006
+ },
1007
+ dispose() {
1008
+ socket.removeListener('data', onData)
1009
+ socket.removeListener('error', onError)
1010
+ socket.removeListener('close', onClose)
1011
+ },
1012
+ }
1013
+ }
1014
+
1015
+ /** Open one TCP/TLS connection, resolving only once the transport is up. */
1016
+ function openSmtpSocket(endpoint, timeoutMs) {
1017
+ return new Promise((resolve, reject) => {
1018
+ const onError = (error) => { socket.destroy(); reject(error) }
1019
+ const socket = endpoint.secure
1020
+ ? tls.connect({ host: endpoint.host, port: endpoint.port, servername: endpoint.host })
1021
+ : net.connect({ host: endpoint.host, port: endpoint.port })
1022
+ socket.once('error', onError)
1023
+ socket.setTimeout(timeoutMs, () => {
1024
+ socket.destroy()
1025
+ reject(Object.assign(new Error(`SMTP 连接超时(${timeoutMs}ms)`), { code: 'smtp-timeout' }))
1026
+ })
1027
+ socket.once(endpoint.secure ? 'secureConnect' : 'connect', () => {
1028
+ socket.removeListener('error', onError)
1029
+ socket.setTimeout(0)
1030
+ resolve(socket)
1031
+ })
1032
+ })
1033
+ }
1034
+
1035
+ /** Run one SMTP verb and fail the whole dialogue when its reply is not 2xx/3xx. */
1036
+ async function smtpCommand(reader, socket, line, accept = [2, 3]) {
1037
+ if (line !== null) socket.write(`${line}\r\n`)
1038
+ const reply = await reader.reply()
1039
+ if (!accept.includes(Math.floor(reply.code / 100))) {
1040
+ throw Object.assign(new Error(`SMTP 拒绝:${reply.text}`), { code: 'smtp-rejected', reply: reply.text, status: reply.code })
1041
+ }
1042
+ return reply
1043
+ }
1044
+
1045
+ /**
1046
+ * Deliver one already-composed message through a real SMTP dialogue.
1047
+ *
1048
+ * Sequence: greeting → EHLO → (STARTTLS) → EHLO → AUTH PLAIN (fallback LOGIN)
1049
+ * → MAIL FROM → RCPT TO → DATA → QUIT. `endpoint.pass` never appears in an
1050
+ * error message or a log line.
1051
+ * @returns {Promise<{ok: true, acceptedAt: string}>}
1052
+ */
1053
+ export async function sendSmtpMessage(endpoint, mail, { timeoutMs = 20_000, now = () => new Date() } = {}) {
1054
+ let socket = await openSmtpSocket(endpoint, timeoutMs)
1055
+ let reader = attachSmtpReader(socket)
1056
+ const deadline = setTimeout(() => { socket.destroy() }, timeoutMs)
1057
+ deadline.unref?.()
1058
+ try {
1059
+ await smtpCommand(reader, socket, null)
1060
+ const identify = `EHLO dsh-sidecard-ask`
1061
+ const greeting = await smtpCommand(reader, socket, identify)
1062
+ // Only upgrade when the server says it can. A relay that never advertises
1063
+ // STARTTLS answers 500 to the verb, and a 587 endpoint on a local network
1064
+ // would then look "broken" for a step nobody asked for.
1065
+ if (endpoint.secure === false && endpoint.starttls !== false && /\bSTARTTLS\b/i.test(greeting.text)) {
1066
+ await smtpCommand(reader, socket, 'STARTTLS', [2])
1067
+ reader.dispose()
1068
+ const upgraded = await new Promise((resolve, reject) => {
1069
+ const secureSocket = tls.connect({ socket, servername: endpoint.host }, () => resolve(secureSocket))
1070
+ secureSocket.once('error', reject)
1071
+ })
1072
+ socket = upgraded
1073
+ reader = attachSmtpReader(socket)
1074
+ await smtpCommand(reader, socket, identify)
1075
+ }
1076
+ const token = Buffer.from(`\u0000${endpoint.user}\u0000${endpoint.pass}`, 'utf8').toString('base64')
1077
+ // PLAIN is one round trip; LOGIN is the fallback for relays that only
1078
+ // speak it. A rejected PLAIN leaves the session usable, so retrying is safe.
1079
+ let accepted = false
1080
+ try {
1081
+ const reply = await smtpCommand(reader, socket, `AUTH PLAIN ${token}`, [2, 3])
1082
+ if (Math.floor(reply.code / 100) === 3) await smtpCommand(reader, socket, token, [2])
1083
+ accepted = true
1084
+ } catch (error) {
1085
+ if (error?.code !== 'smtp-rejected') throw error
1086
+ }
1087
+ if (accepted === false) {
1088
+ await smtpCommand(reader, socket, 'AUTH LOGIN')
1089
+ await smtpCommand(reader, socket, Buffer.from(endpoint.user, 'utf8').toString('base64'), [3])
1090
+ await smtpCommand(reader, socket, Buffer.from(endpoint.pass, 'utf8').toString('base64'), [2])
1091
+ }
1092
+ await smtpCommand(reader, socket, `MAIL FROM:<${endpoint.from}>`)
1093
+ await smtpCommand(reader, socket, `RCPT TO:<${mail.to}>`)
1094
+ await smtpCommand(reader, socket, 'DATA', [3])
1095
+ // Dot-stuffing: a body line starting with "." would end DATA early.
1096
+ const payload = mail.raw.replace(/^\./gm, '..')
1097
+ socket.write(`${payload}\r\n.\r\n`)
1098
+ await smtpCommand(reader, socket, null)
1099
+ try { await smtpCommand(reader, socket, 'QUIT') } catch { /* the mail is already accepted */ }
1100
+ return { ok: true, acceptedAt: now().toISOString() }
1101
+ } finally {
1102
+ clearTimeout(deadline)
1103
+ reader.dispose()
1104
+ socket.destroy()
1105
+ }
1106
+ }
1107
+
504
1108
  /**
505
1109
  * Reduce any thrown value to a wire error the Client can present.
506
1110
  * @param {unknown} error - the thrown value.
@@ -618,12 +1222,13 @@ export function isTrustedRequest(req, trustedHosts = []) {
618
1222
  // ────────────────────────────────────────────────────────────────────────────
619
1223
 
620
1224
  /** Write one JSON response. */
621
- function writeJson(res, status, value) {
1225
+ function writeJson(res, status, value, extraHeaders = {}) {
622
1226
  const body = JSON.stringify(value)
623
1227
  res.writeHead(status, {
624
1228
  'content-type': 'application/json; charset=utf-8',
625
1229
  'cache-control': 'no-store',
626
1230
  'content-length': Buffer.byteLength(body),
1231
+ ...extraHeaders,
627
1232
  })
628
1233
  res.end(body)
629
1234
  }
@@ -633,9 +1238,15 @@ function writeOk(res, value) {
633
1238
  writeJson(res, 200, { ok: true, value })
634
1239
  }
635
1240
 
636
- /** Write one `{ok:false,error}` response. */
637
- function writeError(res, status, code, message) {
638
- writeJson(res, status, { ok: false, error: { code, message } })
1241
+ /**
1242
+ * Write one `{ok:false,error}` response.
1243
+ *
1244
+ * `extraHeaders` exists because `writeHead` replaces the header bag: a value
1245
+ * set with `setHeader` before this call would be dropped (the 429 throttle
1246
+ * relies on `retry-after` surviving).
1247
+ */
1248
+ function writeError(res, status, code, message, extraHeaders = {}) {
1249
+ writeJson(res, status, { ok: false, error: { code, message } }, extraHeaders)
639
1250
  }
640
1251
 
641
1252
  /**
@@ -1377,6 +1988,25 @@ export function apply(ctx, patchConfig) {
1377
1988
  */
1378
1989
  let clientReport = null
1379
1990
 
1991
+ /** Send-rate gate for `/feedback` (one mail per 30s, five per hour). */
1992
+ const feedbackGate = createFeedbackGate()
1993
+
1994
+ /**
1995
+ * Best-effort DSH version for the feedback environment block.
1996
+ * Every lookup is optional: a missing service must not fail the send.
1997
+ */
1998
+ const dshVersionOf = () => {
1999
+ try {
2000
+ const runtime = ctx.get('webRuntime')
2001
+ const version = runtime?.version ?? runtime?.harnessVersion
2002
+ if (typeof version === 'string' && version !== '') return version
2003
+ } catch {
2004
+ /* optional service */
2005
+ }
2006
+ const fromEnv = typeof process.env.DSH_VERSION === 'string' ? process.env.DSH_VERSION.trim() : ''
2007
+ return fromEnv
2008
+ }
2009
+
1380
2010
  /** Allow-listed, size-bounded view of one Client report. */
1381
2011
  function sanitizeClientReport(raw) {
1382
2012
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
@@ -1442,10 +2072,16 @@ export function apply(ctx, patchConfig) {
1442
2072
 
1443
2073
  /** `/state` — the Client's single source of truth at boot and after a save. */
1444
2074
  const handleState = (res) => {
2075
+ // Two secrets never reach the page: the recipient stays server-side (the page
2076
+ // gets `hasRecipient` and `toMasked` only), and the SMTP password is
2077
+ // write-only (the page only learns whether one is stored).
2078
+ const publicConfig = { ...state.config }
2079
+ delete publicConfig.feedbackAddress
2080
+ delete publicConfig.feedbackSmtpPass
1445
2081
  writeOk(res, {
1446
2082
  plugin: name,
1447
2083
  version: PLUGIN_VERSION,
1448
- config: state.config,
2084
+ config: publicConfig,
1449
2085
  problems: state.problems,
1450
2086
  provenance: {
1451
2087
  // Computed live: a save in this process must be reflected immediately.
@@ -1459,6 +2095,9 @@ export function apply(ctx, patchConfig) {
1459
2095
  ...engine.capabilities(),
1460
2096
  // Informational: which process-local stream the host bridges.
1461
2097
  streamSource: 'agent/assistant-stream',
2098
+ // Where a settings-page report would go, and why it cannot (if so).
2099
+ // Never carries a password; addresses are masked for display.
2100
+ feedback: describeFeedback(state.config),
1462
2101
  },
1463
2102
  // The Client half's last self-report (null until it posts one).
1464
2103
  client: clientReport,
@@ -1489,6 +2128,66 @@ export function apply(ctx, patchConfig) {
1489
2128
  writeOk(res, { accepted: true, at: report.at })
1490
2129
  }
1491
2130
 
2131
+ /**
2132
+ * `/feedback` — mail one settings-page report to the configured recipient.
2133
+ *
2134
+ * Failure codes are the Client's contract: `feedback-unconfigured` (no
2135
+ * recipient or no SMTP), `feedback-invalid` (bounds), `feedback-too-fast` /
2136
+ * `feedback-too-many` (gate), `feedback-send-failed` (relay refused). The report body is never
2137
+ * logged; only kind, masked recipient and outcome are.
2138
+ */
2139
+ const handleFeedback = async (req, res) => {
2140
+ let body
2141
+ try {
2142
+ body = await readJsonBody(req)
2143
+ } catch (error) {
2144
+ writeError(res, 400, toWireError(error, 'bad-request').code, toWireError(error).message)
2145
+ return
2146
+ }
2147
+ const input = sanitizeFeedbackInput(body)
2148
+ if (input.ok !== true) {
2149
+ writeError(res, 400, 'feedback-invalid', input.message)
2150
+ return
2151
+ }
2152
+ const status = describeFeedback(state.config)
2153
+ if (status.configured !== true) {
2154
+ writeError(res, 503, 'feedback-unconfigured', status.reason === '' ? '未配置反馈邮箱' : status.reason)
2155
+ return
2156
+ }
2157
+ const gate = feedbackGate.check()
2158
+ if (gate.ok !== true) {
2159
+ const retryAfter = String(Math.max(1, Math.ceil((gate.retryAfterMs ?? 1000) / 1000)))
2160
+ writeError(res, 429, gate.code, gate.message, { 'retry-after': retryAfter })
2161
+ return
2162
+ }
2163
+ const smtp = resolveSmtp(state.config)
2164
+ if (smtp.ok !== true) {
2165
+ writeError(res, 503, 'feedback-unconfigured', smtp.reason)
2166
+ return
2167
+ }
2168
+ // Plaintext recipient stays in this scope; it never reaches a response body.
2169
+ const to = feedbackRecipient(state.config)
2170
+ const now = new Date()
2171
+ const mail = composeFeedbackMail({
2172
+ version: PLUGIN_VERSION,
2173
+ input: input.value,
2174
+ environment: describeEnvironment({ version: PLUGIN_VERSION, dshVersion: dshVersionOf(), now }),
2175
+ now,
2176
+ to,
2177
+ from: smtp.endpoint.from,
2178
+ })
2179
+ try {
2180
+ await sendSmtpMessage(smtp.endpoint, { to, raw: mail.raw, subject: mail.subject })
2181
+ feedbackGate.record()
2182
+ ctx.logger?.info?.(`[sidecard-ask] feedback sent to ${maskEmail(to)} kind=${input.value.kind}`)
2183
+ writeOk(res, { sent: true, at: now.toISOString(), transport: 'smtp', toMasked: maskEmail(to) })
2184
+ } catch (error) {
2185
+ const wire = toWireError(error, 'feedback-send-failed')
2186
+ ctx.logger?.warn?.(`[sidecard-ask] feedback failed to ${maskEmail(to)}: ${wire.code} ${wire.message}`)
2187
+ writeError(res, 502, 'feedback-send-failed', wire.message)
2188
+ }
2189
+ }
2190
+
1492
2191
  /** `/ask` — one SSE stream per side answer. */
1493
2192
  const handleAsk = async (req, res) => {
1494
2193
  let body
@@ -1549,7 +2248,12 @@ export function apply(ctx, patchConfig) {
1549
2248
  return
1550
2249
  }
1551
2250
  const problems = []
1552
- const accepted = sanitizeLayer(body, problems)
2251
+ // The SMTP password is write-only: `/state` never hands it back, so the
2252
+ // settings form always submits an empty string for an untouched password.
2253
+ // Treat that empty value as "leave it alone" instead of erasing it.
2254
+ const patch = { ...body }
2255
+ if (patch.feedbackSmtpPass === '') delete patch.feedbackSmtpPass
2256
+ const accepted = sanitizeLayer(patch, problems)
1553
2257
  if (problems.length > 0) {
1554
2258
  writeError(res, 400, 'invalid-config', problems.join(';'))
1555
2259
  return
@@ -1576,7 +2280,7 @@ export function apply(ctx, patchConfig) {
1576
2280
  * The HTTP methods the plugin serves; kept in one place because the route
1577
2281
  * registration has two shapes (see below).
1578
2282
  */
1579
- const API_METHODS = ['state', 'ask', 'cancel', 'config', 'reset', 'diagnose']
2283
+ const API_METHODS = ['state', 'ask', 'cancel', 'config', 'reset', 'diagnose', 'feedback']
1580
2284
 
1581
2285
  /** One request → one response; the dispatcher owns path parsing and the fence. */
1582
2286
  const routeHandler = async (req, res) => {
@@ -1610,6 +2314,7 @@ export function apply(ctx, patchConfig) {
1610
2314
  else if (method === 'config') await handleConfig(req, res)
1611
2315
  else if (method === 'reset') handleReset(req, res)
1612
2316
  else if (method === 'diagnose') await handleDiagnose(req, res)
2317
+ else if (method === 'feedback') await handleFeedback(req, res)
1613
2318
  else writeError(res, 404, 'not-found', `未知接口 "${method}"`)
1614
2319
  } catch (error) {
1615
2320
  const wire = toWireError(error)