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.
- package/CHANGELOG.md +36 -0
- package/README.md +89 -2
- package/client.js +444 -32
- package/config.schema.json +13 -0
- package/cordis.patch.yml +20 -0
- package/lib/core/config.js +40 -0
- package/lib/core/errors.js +6 -0
- package/lib/host/api.js +24 -3
- package/lib/host/feedback.js +434 -0
- package/lib/version.js +1 -1
- package/package.json +6 -3
package/config.schema.json
CHANGED
|
@@ -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
|
package/lib/core/config.js
CHANGED
|
@@ -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 }
|
package/lib/core/errors.js
CHANGED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-conversation-recycle-bin",
|
|
3
|
-
"version": "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",
|
|
@@ -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,
|