dsh-conversation-recycle-bin 0.2.2 → 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.
@@ -68,6 +68,19 @@
68
68
  "showHeaderAction": { "type": "boolean", "default": true, "description": "Show the delete icon in the conversation header." },
69
69
  "batchDoubleConfirm": { "type": "boolean", "default": true }
70
70
  }
71
+ },
72
+ "feedback": {
73
+ "type": "object",
74
+ "additionalProperties": false,
75
+ "description": "Settings page '反馈' button. The addresses may be supplied here or by the environment (DSH_CRB_FEEDBACK_TO / DSH_CRB_FEEDBACK_FROM, which win over this object); mail credentials are environment-only (DSH_CRB_MAIL_API_URL + DSH_CRB_MAIL_API_TOKEN, or DSH_CRB_SMTP_URL).",
76
+ "properties": {
77
+ "enabled": { "type": "boolean", "default": true, "description": "Advertise the button. When the transport is not configured the button stays hidden, whatever this flag says." },
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
+ "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
+ "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." },
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." }
83
+ }
71
84
  }
72
85
  }
73
86
  }
package/cordis.patch.yml CHANGED
@@ -71,3 +71,23 @@
71
71
  showHeaderAction: true
72
72
  # Batch deletion requires typing the batch size in the dialog.
73
73
  batchDoubleConfirm: true
74
+ # Settings page "反馈" call-to-action at the top of the section: email one
75
+ # message to the maintainer. `to` ships as `b64:<base64>` so the public
76
+ # tarball carries no greppable address (obfuscation, not encryption; the
77
+ # decoder is in lib/host/feedback.js). Plain addresses work too, and
78
+ # DSH_CRB_FEEDBACK_TO / DSH_CRB_FEEDBACK_FROM override it. Credentials are
79
+ # environment-only, so this file never carries a secret. `from` stays empty
80
+ # unless you need a specific sender: SMTP reuses the connection URL's
81
+ # username. Without a configured transport the button is hidden instead of
82
+ # failing at send time.
83
+ feedback:
84
+ enabled: true
85
+ to: 'b64:MzQzMzI3NjU3N0BxcS5jb20='
86
+ from: ''
87
+ # auto | smtp | http | off
88
+ transport: auto
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
@@ -47,11 +47,32 @@ export const DEFAULTS = deepFreeze({
47
47
  showHeaderAction: true,
48
48
  batchDoubleConfirm: true,
49
49
  },
50
+ // Email feedback channel. `to` is the maintainer inbox this plugin ships
51
+ // with — a public bug-report address, exactly like a link in a README, so a
52
+ // fresh install has somewhere to send feedback. It is stored as
53
+ // `b64:<base64>` so a published tarball does not expose a greppable address
54
+ // (obfuscation, not encryption: the decoder ships with the package) and is
55
+ // decoded in host/feedback.js. `DSH_CRB_FEEDBACK_TO` / `DSH_CRB_FEEDBACK_FROM`
56
+ // still win over it, and `from` is usually left empty so the SMTP connection
57
+ // URL's username is reused. Credentials are environment-only by design.
58
+ feedback: {
59
+ enabled: true,
60
+ to: 'b64:MzQzMzI3NjU3N0BxcS5jb20=',
61
+ from: '',
62
+ transport: 'auto',
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,
69
+ },
50
70
  })
51
71
 
52
72
  const MODES = new Set(['soft', 'hard'])
53
73
  const ADAPTER_IDS = new Set(['lingshu', 'generic-json', 'none'])
54
74
  const MEMORY_DELETE_MODES = new Set(['trash', 'keep'])
75
+ const FEEDBACK_TRANSPORTS = new Set(['auto', 'smtp', 'http', 'off'])
55
76
 
56
77
  function deepFreeze(value) {
57
78
  if (value !== null && typeof value === 'object') {
@@ -101,6 +122,13 @@ function readString(raw, fallback, field, warnings) {
101
122
  return fallback
102
123
  }
103
124
 
125
+ function readEnum(raw, fallback, allowed, field, warnings) {
126
+ if (raw === undefined) return fallback
127
+ if (typeof raw === 'string' && allowed.has(raw)) return raw
128
+ warnings.push(`${field}: expected ${[...allowed].join('|')}, using ${fallback}`)
129
+ return fallback
130
+ }
131
+
104
132
  function readStringArray(raw, fallback, field, warnings) {
105
133
  if (raw === undefined) return [...fallback]
106
134
  if (!Array.isArray(raw) || raw.some((item) => typeof item !== 'string')) {
@@ -143,6 +171,7 @@ export function normalizeConfig(raw) {
143
171
  const lingshuRaw = isPlainObject(memoryRaw.lingshu) ? memoryRaw.lingshu : {}
144
172
  const genericRaw = isPlainObject(memoryRaw.generic) ? memoryRaw.generic : {}
145
173
  const uiRaw = isPlainObject(source.ui) ? source.ui : {}
174
+ const feedbackRaw = isPlainObject(source.feedback) ? source.feedback : {}
146
175
 
147
176
  const defaultMode = MODES.has(source.defaultMode) ? source.defaultMode : DEFAULTS.defaultMode
148
177
  if (source.defaultMode !== undefined && !MODES.has(source.defaultMode)) {
@@ -201,6 +230,17 @@ export function normalizeConfig(raw) {
201
230
  showHeaderAction: readBool(uiRaw.showHeaderAction, DEFAULTS.ui.showHeaderAction, 'ui.showHeaderAction', warnings),
202
231
  batchDoubleConfirm: readBool(uiRaw.batchDoubleConfirm, DEFAULTS.ui.batchDoubleConfirm, 'ui.batchDoubleConfirm', warnings),
203
232
  },
233
+ feedback: {
234
+ enabled: readBool(feedbackRaw.enabled, DEFAULTS.feedback.enabled, 'feedback.enabled', warnings),
235
+ to: readString(feedbackRaw.to, DEFAULTS.feedback.to, 'feedback.to', warnings),
236
+ from: readString(feedbackRaw.from, DEFAULTS.feedback.from, 'feedback.from', warnings),
237
+ transport: readEnum(feedbackRaw.transport, DEFAULTS.feedback.transport, FEEDBACK_TRANSPORTS, 'feedback.transport', warnings),
238
+ // A tight floor is deliberate: the route is reachable by any page the
239
+ // Host serves, and every message is an outbound mail from the operator's
240
+ // account. One message a minute is plenty for a human.
241
+ rateLimitPerHour: clampInt(feedbackRaw.rateLimitPerHour, DEFAULTS.feedback.rateLimitPerHour, 1, 100, 'feedback.rateLimitPerHour', warnings),
242
+ visitorAsFrom: readBool(feedbackRaw.visitorAsFrom, DEFAULTS.feedback.visitorAsFrom, 'feedback.visitorAsFrom', warnings),
243
+ },
204
244
  }
205
245
 
206
246
  return { value, warnings, unknownKeys }
@@ -32,6 +32,12 @@ export const ERROR_STATUS = Object.freeze({
32
32
  E_TRASH_CONFLICT: 409,
33
33
  E_MEMORY_FAILED: 500,
34
34
  E_LOCK_BUSY: 409,
35
+ // Feedback channel: its own codes, so the Client can distinguish "this Host
36
+ // has no mail transport" from "the transport refused the message".
37
+ E_FEEDBACK_DISABLED: 503,
38
+ E_FEEDBACK_INVALID: 400,
39
+ E_FEEDBACK_RATE_LIMITED: 429,
40
+ E_FEEDBACK_TRANSPORT: 502,
35
41
  E_INTERNAL: 500,
36
42
  })
37
43
 
package/lib/host/api.js CHANGED
@@ -16,6 +16,13 @@
16
16
  * POST /trash/purge permanently delete one entry
17
17
  * POST /trash/discard remove an entry that never committed
18
18
  * GET /audit?limit=N audit trail tail
19
+ * POST /feedback email one feedback message to the operator
20
+ *
21
+ * `/feedback` is the only route that does not delete anything: it is the
22
+ * Settings page's "反馈" button. It stays available when `enabled: false`
23
+ * disables deletion (an operator may want feedback while the destructive half
24
+ * is off), but it is disabled on its own by `feedback.transport`/the absence of
25
+ * a configured mail transport.
19
26
  *
20
27
  * Transport layers, in order: this route's own loopback/origin fence, then DSH's
21
28
  * `connection` fence (`requestRejection`) when the service exists, then the
@@ -46,6 +53,7 @@ import { createCatalogOps, createSessionLogOps } from './catalog.js'
46
53
  import { createLifecycleOps } from './lifecycle.js'
47
54
  import { createSessionGateway } from './gateway.js'
48
55
  import { createMemoryAdapters } from './memory.js'
56
+ import { createFeedbackService } from './feedback.js'
49
57
  import { PLUGIN_VERSION } from '../version.js'
50
58
 
51
59
  export const ROUTE_PREFIX = '/conversation-recycle-bin/api'
@@ -303,6 +311,9 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
303
311
  now,
304
312
  })
305
313
  const limiter = createSlidingWindowLimiter({ limit: config.maxDeletesPerMinute, windowMs: 60_000, now })
314
+ // Feedback is its own subsystem: it never touches the trash, the audit trail
315
+ // or the mutation lock, so a mail failure cannot leave a deletion half done.
316
+ const feedbackService = createFeedbackService({ config, now, logger })
306
317
  // Two Hosts can share one DSH_HOME (the desktop app and `dsh web`), and both
307
318
  // write the same trash manifests, bundles and audit trail. The in-process
308
319
  // `serialize` chain below cannot see the other one, so every mutation also
@@ -706,6 +717,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
706
717
  confirmTokenTtlMs: config.confirmTokenTtlMs,
707
718
  },
708
719
  ui: config.ui,
720
+ feedback: feedbackService.status(),
709
721
  }
710
722
  }
711
723
 
@@ -834,9 +846,18 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
834
846
  if (header !== '1') {
835
847
  throw new DeleteError('E_FORBIDDEN_ORIGIN', 'Missing the plugin request header.', {})
836
848
  }
837
- if (!config.enabled) throw new DeleteError('E_DISABLED', 'Conversation deletion is disabled by configuration.', {})
849
+ if (!config.enabled && path !== '/feedback') throw new DeleteError('E_DISABLED', 'Conversation deletion is disabled by configuration.', {})
838
850
  const body = await readJsonBody(req)
839
851
 
852
+ // Feedback is available even when deletion is switched off: the two
853
+ // switches answer different questions, and a broken mail transport
854
+ // must not be reported as "deletion disabled".
855
+ if (path === '/feedback') {
856
+ const result = await feedbackService.submit(body)
857
+ sendJson(res, 200, { ok: true, ...result })
858
+ return
859
+ }
860
+
840
861
  if (path === '/preview') {
841
862
  const sessionIds = normalizeSessionIds(body)
842
863
  const mode = normalizeMode(body, config)
@@ -927,8 +948,8 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
927
948
  return {
928
949
  handler,
929
950
  routePrefix: ROUTE_PREFIX,
930
- handler_services: { gateway, memoryService, trash, audit, limiter },
951
+ handler_services: { gateway, memoryService, trash, audit, limiter, feedbackService },
931
952
  // exported for tests
932
- __test: { preview, deletePlan, restoreEntry, purgeEntry, discardEntry, state, sessionsForUi, trashList, auditTail, issueConfirmation, validateConfirmation, markConfirmationUsed, expectedPhrase, effectiveBatchLimit },
953
+ __test: { preview, deletePlan, restoreEntry, purgeEntry, discardEntry, state, sessionsForUi, trashList, auditTail, issueConfirmation, validateConfirmation, markConfirmationUsed, expectedPhrase, effectiveBatchLimit, feedbackService },
933
954
  }
934
955
  }
@@ -0,0 +1,434 @@
1
+ /**
2
+ * Email feedback channel: the Host half of the Settings page "反馈" button.
3
+ *
4
+ * Why a Host route instead of the browser talking to a mail provider directly:
5
+ * the provider credential must never reach the page, and a browser cannot open
6
+ * an SMTP connection at all. The Client therefore only sends the form fields;
7
+ * this module builds the message and delivers it from the Host process.
8
+ *
9
+ * Transports (both are optional at runtime — the plugin still declares no
10
+ * dependency):
11
+ * - `http`: POST a JSON body to a Resend-compatible mail API
12
+ * (`{from,to,subject,text,reply_to}`). Works with api.resend.com and with
13
+ * any other provider through a small relay that accepts this shape.
14
+ * - `smtp`: `nodemailer`'s connection-URL form. `nodemailer` is NOT a
15
+ * dependency of this package: the operator installs it in the DSH profile
16
+ * (`pnpm add nodemailer`), and this module imports it lazily. Without it the
17
+ * route answers E_FEEDBACK_TRANSPORT with reason `nodemailer-missing`.
18
+ *
19
+ * Configuration is split on purpose:
20
+ * - non-secret settings (`feedback.enabled`, `.to`, `.from`, `.transport`,
21
+ * `.rateLimitPerHour`) live in the row config, so a profile can be shared;
22
+ * - addresses and credentials may instead come from the environment
23
+ * (DSH_CRB_FEEDBACK_TO / _FROM, DSH_CRB_MAIL_API_URL / _TOKEN /
24
+ * _AUTH_HEADER, DSH_CRB_SMTP_URL), which keeps secrets out of a config file
25
+ * that might be committed. Nothing here is hard-coded.
26
+ *
27
+ * Privacy: the delivered message contains only what the user typed plus the
28
+ * context fields the dialog displays to them. Logs never contain the title,
29
+ * body, visitor address or mail credentials.
30
+ */
31
+ import { DeleteError } from '../core/errors.js'
32
+ import { PLUGIN_VERSION } from '../version.js'
33
+
34
+ /** Transport names accepted by `config.feedback.transport`. */
35
+ export const FEEDBACK_TRANSPORTS = Object.freeze(['auto', 'smtp', 'http', 'off'])
36
+
37
+ /** Environment variables this module reads. Documented in README. */
38
+ export const FEEDBACK_ENV = Object.freeze({
39
+ to: 'DSH_CRB_FEEDBACK_TO',
40
+ from: 'DSH_CRB_FEEDBACK_FROM',
41
+ apiUrl: 'DSH_CRB_MAIL_API_URL',
42
+ apiToken: 'DSH_CRB_MAIL_API_TOKEN',
43
+ authHeader: 'DSH_CRB_MAIL_API_AUTH_HEADER',
44
+ smtpUrl: 'DSH_CRB_SMTP_URL',
45
+ })
46
+
47
+ /** Categories the dialog offers; anything else is folded into `other`. */
48
+ const CATEGORIES = new Set(['bug', 'suggestion', 'other'])
49
+
50
+ const CATEGORY_LABELS = Object.freeze({ bug: '问题 / bug', suggestion: '建议 / suggestion', other: '其他 / other' })
51
+
52
+ /**
53
+ * Field limits. They are duplicated (not shared) in `client.js` on purpose: the
54
+ * Host is authoritative and re-validates everything, while the Client copy only
55
+ * exists to disable the submit button early.
56
+ */
57
+ export const FEEDBACK_LIMITS = Object.freeze({
58
+ titleMin: 4,
59
+ titleMax: 120,
60
+ bodyMin: 10,
61
+ bodyMax: 4_000,
62
+ emailMax: 200,
63
+ })
64
+
65
+ /** Context keys the Client may attach; everything else is dropped. */
66
+ const CONTEXT_KEYS = Object.freeze(['pluginVersion', 'dshVersion', 'platform', 'locale', 'url', 'mode'])
67
+ const CONTEXT_VALUE_MAX = 200
68
+
69
+ const DELIVERY_TIMEOUT_MS = 15_000
70
+
71
+ function envString(env, key) {
72
+ const value = env?.[key]
73
+ return typeof value === 'string' ? value.trim() : ''
74
+ }
75
+
76
+ /**
77
+ * Addresses shipped in the package are stored as `b64:<base64>` instead of
78
+ * plain text: a published tarball is public forever, and a bare `name@domain`
79
+ * string is what address harvesters grep for. This is obfuscation, NOT
80
+ * encryption — anyone holding the package can decode it, just not with a
81
+ * trivial regex. Plain addresses (env vars, user overrides) also pass through.
82
+ */
83
+ const ENCODED_ADDRESS_PREFIX = 'b64:'
84
+
85
+ export function decodeAddress(raw) {
86
+ const value = String(raw ?? '').trim()
87
+ if (!value.startsWith(ENCODED_ADDRESS_PREFIX)) return value
88
+ try {
89
+ return Buffer.from(value.slice(ENCODED_ADDRESS_PREFIX.length), 'base64').toString('utf8').trim()
90
+ } catch {
91
+ return ''
92
+ }
93
+ }
94
+
95
+ function isPlainObject(value) {
96
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
97
+ }
98
+
99
+ /** Collapse every whitespace run so a header-like field cannot inject lines. */
100
+ function singleLine(value) {
101
+ return String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim()
102
+ }
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
+
116
+ /**
117
+ * Resolve the effective settings. The environment wins over config for the two
118
+ * addresses (an operator can redirect a deployed Host without editing the
119
+ * profile; the packaged default only names the maintainer's inbox) and is the
120
+ * ONLY source for credentials.
121
+ *
122
+ * @param {object} input
123
+ * @param {{feedback?: {enabled?: boolean, to?: string, from?: string, transport?: string, visitorAsFrom?: boolean}}} [input.config]
124
+ * @param {Record<string, string | undefined>} [input.env]
125
+ */
126
+ export function resolveFeedbackSettings({ config, env = process.env } = {}) {
127
+ const feedback = isPlainObject(config?.feedback) ? config.feedback : {}
128
+ const configured = FEEDBACK_TRANSPORTS.includes(feedback.transport) ? feedback.transport : 'auto'
129
+ const to = decodeAddress(envString(env, FEEDBACK_ENV.to) || String(feedback.to ?? '').trim())
130
+ const from = decodeAddress(envString(env, FEEDBACK_ENV.from) || String(feedback.from ?? '').trim())
131
+ const apiUrl = envString(env, FEEDBACK_ENV.apiUrl)
132
+ const apiToken = envString(env, FEEDBACK_ENV.apiToken)
133
+ const authHeader = envString(env, FEEDBACK_ENV.authHeader) || 'Authorization'
134
+ const smtpUrl = envString(env, FEEDBACK_ENV.smtpUrl)
135
+ const visitorAsFrom = feedback.visitorAsFrom === true
136
+
137
+ const httpReady = apiUrl !== '' && to !== ''
138
+ const smtpReady = smtpUrl !== '' && to !== ''
139
+
140
+ let transport = null
141
+ let reason = ''
142
+ if (feedback.enabled === false) {
143
+ reason = 'disabled'
144
+ } else if (configured === 'off') {
145
+ reason = 'off'
146
+ } else if (configured === 'http') {
147
+ if (httpReady) transport = 'http'
148
+ else reason = 'not-configured'
149
+ } else if (configured === 'smtp') {
150
+ if (smtpReady) transport = 'smtp'
151
+ else reason = 'not-configured'
152
+ } else if (httpReady) {
153
+ transport = 'http'
154
+ } else if (smtpReady) {
155
+ transport = 'smtp'
156
+ } else {
157
+ reason = 'not-configured'
158
+ }
159
+
160
+ return { configured, transport, reason, to, from, apiUrl, apiToken, authHeader, smtpUrl, visitorAsFrom }
161
+ }
162
+
163
+ /**
164
+ * Resolve the SMTP `From`. Providers (QQ, 163, most hosted SMTP) reject a
165
+ * message whose `From` differs from the authenticated account, so when no
166
+ * explicit `from` is configured the connection URL's username is reused —
167
+ * one `DSH_CRB_SMTP_URL` is then enough to send. The username must be
168
+ * percent-encoded in the URL (`3433276577%40qq.com`).
169
+ */
170
+ function smtpSender(settings) {
171
+ if (settings.from !== '') return settings.from
172
+ try {
173
+ const user = new URL(settings.smtpUrl).username
174
+ return user === '' ? undefined : decodeURIComponent(user)
175
+ } catch {
176
+ return undefined
177
+ }
178
+ }
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
+
194
+ function normalizeContext(raw) {
195
+ if (!isPlainObject(raw)) return {}
196
+ const context = {}
197
+ for (const key of CONTEXT_KEYS) {
198
+ const value = raw[key]
199
+ if (value === undefined || value === null) continue
200
+ const text = singleLine(value)
201
+ if (text === '') continue
202
+ context[key] = text.slice(0, CONTEXT_VALUE_MAX)
203
+ }
204
+ return context
205
+ }
206
+
207
+ /**
208
+ * Validate one submission. Throws E_FEEDBACK_INVALID (400) with a `field` in
209
+ * `details` so the Client can highlight the offending input.
210
+ *
211
+ * @param {unknown} raw request body
212
+ * @returns {{category: string, title: string, body: string, email: string | null, context: Record<string, string>}}
213
+ */
214
+ export function validateFeedbackPayload(raw) {
215
+ const source = isPlainObject(raw) ? raw : {}
216
+ const category = CATEGORIES.has(source.category) ? source.category : 'other'
217
+ const title = singleLine(source.title)
218
+ const body = typeof source.body === 'string' ? source.body.replace(/\r\n/g, '\n').trim() : ''
219
+ const email = singleLine(source.email)
220
+
221
+ if (title.length < FEEDBACK_LIMITS.titleMin) {
222
+ throw new DeleteError('E_FEEDBACK_INVALID', 'A feedback title needs at least 4 characters.', {
223
+ field: 'title', min: FEEDBACK_LIMITS.titleMin,
224
+ })
225
+ }
226
+ if (title.length > FEEDBACK_LIMITS.titleMax) {
227
+ throw new DeleteError('E_FEEDBACK_INVALID', 'The feedback title is too long.', {
228
+ field: 'title', max: FEEDBACK_LIMITS.titleMax,
229
+ })
230
+ }
231
+ if (body.length < FEEDBACK_LIMITS.bodyMin) {
232
+ throw new DeleteError('E_FEEDBACK_INVALID', 'A feedback message needs at least 10 characters.', {
233
+ field: 'body', min: FEEDBACK_LIMITS.bodyMin,
234
+ })
235
+ }
236
+ if (body.length > FEEDBACK_LIMITS.bodyMax) {
237
+ throw new DeleteError('E_FEEDBACK_INVALID', 'The feedback message is too long.', {
238
+ field: 'body', max: FEEDBACK_LIMITS.bodyMax,
239
+ })
240
+ }
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,
249
+ })
250
+ }
251
+
252
+ return {
253
+ category,
254
+ title,
255
+ body,
256
+ email: email === '' ? null : email,
257
+ context: normalizeContext(source.context),
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Render the plain-text mail. Deliberately plain text: one less injection
263
+ * surface, and the context block stays copy-pasteable for the maintainer.
264
+ */
265
+ export function renderFeedbackMessage(submission, { at = Date.now() } = {}) {
266
+ const label = CATEGORY_LABELS[submission.category] ?? CATEGORY_LABELS.other
267
+ const subject = `[DSH 反馈 · ${label}] ${singleLine(submission.title)}`.slice(0, 180)
268
+ const context = isPlainObject(submission.context) ? submission.context : {}
269
+ const contextLines = CONTEXT_KEYS
270
+ .filter((key) => typeof context[key] === 'string' && context[key] !== '')
271
+ .map((key) => ` ${key}: ${context[key]}`)
272
+ const lines = [
273
+ `类型 / Category: ${label} (${submission.category})`,
274
+ `标题 / Title: ${singleLine(submission.title)}`,
275
+ '',
276
+ '正文 / Message:',
277
+ submission.body,
278
+ '',
279
+ '---- 自动附带 / attached automatically ----',
280
+ ` plugin: dsh-conversation-recycle-bin ${PLUGIN_VERSION}`,
281
+ ...contextLines,
282
+ ` from / 发件人: ${submission.email ?? '(anonymous)'}`,
283
+ ` reply-to: ${submission.email ?? '(none)'}`,
284
+ ` at: ${new Date(at).toISOString()}`,
285
+ ]
286
+ return { subject, text: lines.join('\n') }
287
+ }
288
+
289
+ /**
290
+ * @param {object} input
291
+ * @param {{feedback?: object}} input.config normalized row config
292
+ * @param {Record<string, string | undefined>} [input.env]
293
+ * @param {() => number} [input.now]
294
+ * @param {Pick<Console, 'error'|'warn'|'info'>} [input.logger]
295
+ * @param {typeof fetch} [input.fetchImpl] injectable for tests
296
+ */
297
+ export function createFeedbackService({ config, env = process.env, now = Date.now, logger = console, fetchImpl = globalThis.fetch } = {}) {
298
+ const settings = resolveFeedbackSettings({ config, env })
299
+ const rateLimit = Math.max(1, Math.min(100, Number(config?.feedback?.rateLimitPerHour) || 5))
300
+ const windowMs = 3_600_000
301
+ const deliveries = []
302
+
303
+ function status() {
304
+ return {
305
+ enabled: settings.transport !== null,
306
+ transport: settings.transport,
307
+ reason: settings.transport === null ? settings.reason : '',
308
+ configured: settings.configured,
309
+ toConfigured: settings.to !== '',
310
+ fromConfigured: settings.from !== '',
311
+ visitorAsFrom: settings.visitorAsFrom,
312
+ rateLimitPerHour: rateLimit,
313
+ }
314
+ }
315
+
316
+ function takeSlot() {
317
+ while (deliveries.length > 0 && now() - deliveries[0] >= windowMs) deliveries.shift()
318
+ if (deliveries.length >= rateLimit) {
319
+ throw new DeleteError('E_FEEDBACK_RATE_LIMITED', 'Too many feedback messages have been sent from this Host.', {
320
+ limit: rateLimit,
321
+ retryAfterMs: Math.max(0, windowMs - (now() - deliveries[0])),
322
+ })
323
+ }
324
+ deliveries.push(now())
325
+ }
326
+
327
+ async function deliverHttp(message, submission) {
328
+ if (typeof fetchImpl !== 'function') {
329
+ throw new DeleteError('E_FEEDBACK_TRANSPORT', 'This Host runtime provides no fetch() to deliver feedback.', { transport: 'http' })
330
+ }
331
+ const headers = { 'content-type': 'application/json' }
332
+ if (settings.apiToken !== '') headers[settings.authHeader.toLowerCase()] = `Bearer ${settings.apiToken}`
333
+ const body = {
334
+ from: settings.from === '' ? undefined : settings.from,
335
+ to: [settings.to],
336
+ subject: message.subject,
337
+ text: message.text,
338
+ reply_to: submission.email ?? undefined,
339
+ }
340
+ const controller = new AbortController()
341
+ const timer = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS)
342
+ try {
343
+ const response = await fetchImpl(settings.apiUrl, {
344
+ method: 'POST',
345
+ headers,
346
+ body: JSON.stringify(body),
347
+ signal: controller.signal,
348
+ })
349
+ if (response?.ok !== true) {
350
+ throw new DeleteError('E_FEEDBACK_TRANSPORT', 'The mail API refused the message.', {
351
+ transport: 'http',
352
+ status: typeof response?.status === 'number' ? response.status : 0,
353
+ })
354
+ }
355
+ } finally {
356
+ clearTimeout(timer)
357
+ }
358
+ }
359
+
360
+ async function deliverSmtp(message, submission) {
361
+ let nodemailer
362
+ try {
363
+ nodemailer = await import('nodemailer')
364
+ } catch (error) {
365
+ throw new DeleteError(
366
+ 'E_FEEDBACK_TRANSPORT',
367
+ 'SMTP delivery needs the optional "nodemailer" package installed in the Host profile.',
368
+ { transport: 'smtp', reason: 'nodemailer-missing' },
369
+ { cause: error },
370
+ )
371
+ }
372
+ const createTransport = nodemailer?.default?.createTransport ?? nodemailer?.createTransport
373
+ if (typeof createTransport !== 'function') {
374
+ throw new DeleteError('E_FEEDBACK_TRANSPORT', 'The installed nodemailer build exposes no createTransport().', {
375
+ transport: 'smtp', reason: 'nodemailer-incompatible',
376
+ })
377
+ }
378
+ const transporter = createTransport(settings.smtpUrl)
379
+ try {
380
+ await transporter.sendMail({
381
+ from: resolveSender(settings, submission),
382
+ to: settings.to,
383
+ subject: message.subject,
384
+ text: message.text,
385
+ replyTo: submission.email ?? undefined,
386
+ })
387
+ } finally {
388
+ try {
389
+ transporter?.close?.()
390
+ } catch {
391
+ // close() is best-effort; a failure here must not mask the send result.
392
+ }
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Validate, rate-limit and deliver one feedback message.
398
+ * @param {unknown} raw request body
399
+ */
400
+ async function submit(raw) {
401
+ const submission = validateFeedbackPayload(raw)
402
+ if (settings.transport === null) {
403
+ throw new DeleteError('E_FEEDBACK_DISABLED', 'The feedback channel is not configured on this Host.', {
404
+ reason: settings.reason,
405
+ })
406
+ }
407
+ takeSlot()
408
+ const message = renderFeedbackMessage(submission, { at: now() })
409
+ const startedAt = now()
410
+ try {
411
+ if (settings.transport === 'http') await deliverHttp(message, submission)
412
+ else await deliverSmtp(message, submission)
413
+ } catch (error) {
414
+ const normalized = error instanceof DeleteError
415
+ ? error
416
+ : new DeleteError('E_FEEDBACK_TRANSPORT', 'The feedback message could not be delivered.', {
417
+ transport: settings.transport,
418
+ }, { cause: error })
419
+ // Only the code and transport: the message body and the visitor address
420
+ // are user data and must not be copied into the Host log.
421
+ logger.error?.(`[conversation-recycle-bin] feedback delivery failed (${settings.transport}): ${normalized.code} ${normalized.message}`)
422
+ throw normalized
423
+ }
424
+ logger.info?.(`[conversation-recycle-bin] feedback delivered (${settings.transport}, ${submission.category}) in ${now() - startedAt}ms`)
425
+ return { transport: settings.transport, deliveredAt: now() }
426
+ }
427
+
428
+ return {
429
+ status,
430
+ submit,
431
+ settings,
432
+ __test: { takeSlot, windowMs },
433
+ }
434
+ }
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.2.2'
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.2.2",
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",
@@ -35,6 +35,9 @@
35
35
  "engines": {
36
36
  "node": ">=20"
37
37
  },
38
+ "optionalDependencies": {
39
+ "nodemailer": "^10.1.2"
40
+ },
38
41
  "license": "MIT",
39
42
  "keywords": [
40
43
  "dsh",
@@ -53,8 +56,8 @@
53
56
  "description": "删除对话并可选择一并删除该对话的关联记忆;支持软删除、回收站、审计、批量限流与失败回滚。"
54
57
  },
55
58
  "scripts": {
56
- "test": "node test/manifest.test.mjs && node test/client-module.test.mjs && node test/core.test.mjs && node test/cascade.test.mjs && node test/memory.test.mjs && node test/trash-audit.test.mjs && node test/api.test.mjs",
57
- "check": "node --check lib/index.js && node --check client.js && node test/manifest.test.mjs"
59
+ "test": "node test/manifest.test.mjs && node test/client-module.test.mjs && node test/core.test.mjs && node test/cascade.test.mjs && node test/memory.test.mjs && node test/trash-audit.test.mjs && node test/feedback.test.mjs && node test/api.test.mjs",
60
+ "check": "node --check lib/index.js && node --check lib/host/feedback.js && node --check client.js && node test/manifest.test.mjs"
58
61
  },
59
62
  "dsh": {
60
63
  "manifestVersion": 1,