pipework 0.252.1 → 0.254.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.
- package/CHANGELOG.md +11 -0
- package/dist/infra/email/inbound.d.ts +5 -0
- package/dist/infra/email/inbound.d.ts.map +1 -1
- package/dist/infra/email/inbound.js +5 -1
- package/dist/infra/email/inbound.js.map +1 -1
- package/dist/infra/email/index.d.ts +1 -0
- package/dist/infra/email/index.d.ts.map +1 -1
- package/dist/infra/email/index.js +1 -0
- package/dist/infra/email/index.js.map +1 -1
- package/dist/infra/email/namespace.d.ts +5 -0
- package/dist/infra/email/namespace.d.ts.map +1 -1
- package/dist/infra/email/namespace.js +5 -0
- package/dist/infra/email/namespace.js.map +1 -1
- package/dist/infra/email/sanitize.d.ts +10 -0
- package/dist/infra/email/sanitize.d.ts.map +1 -0
- package/dist/infra/email/sanitize.js +331 -0
- package/dist/infra/email/sanitize.js.map +1 -0
- package/dist/infra/support/handle.d.ts +8 -1
- package/dist/infra/support/handle.d.ts.map +1 -1
- package/dist/infra/support/handle.js +19 -4
- package/dist/infra/support/handle.js.map +1 -1
- package/dist/infra/support/namespace.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/infra/email/inbound.ts +10 -1
- package/src/infra/email/index.ts +1 -0
- package/src/infra/email/namespace.ts +5 -0
- package/src/infra/email/sanitize.ts +284 -0
- package/src/infra/support/handle.ts +32 -8
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allow-list HTML sanitiser for mail from strangers. The input is tokenised and the output is built only from
|
|
3
|
+
* allow-listed tags and attributes, every tag re-serialised and every text run and attribute value escaped.
|
|
4
|
+
* No slice of the input is copied through. Output is balanced and `sanitizeHtml(sanitizeHtml(x)) === sanitizeHtml(x)`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const VOID = new Set(['br', 'hr', 'img', 'col'])
|
|
8
|
+
const ALLOWED = new Set([
|
|
9
|
+
'p', 'br', 'div', 'span', 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'sub', 'sup', 'small', 'blockquote', 'pre', 'code',
|
|
10
|
+
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
|
|
11
|
+
'caption', 'colgroup', 'col', 'a', 'img', 'font', 'center',
|
|
12
|
+
])
|
|
13
|
+
/**
|
|
14
|
+
* Dropped together with everything inside them. Wrappers such as form, button, select, option and head are not listed:
|
|
15
|
+
* they are unwrapped (tag gone, text kept) and their dangerous children are dropped one by one.
|
|
16
|
+
*/
|
|
17
|
+
const DROPPED = new Set([
|
|
18
|
+
'script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'input', 'textarea',
|
|
19
|
+
'svg', 'math', 'template', 'noscript', 'title', 'base', 'link', 'meta',
|
|
20
|
+
])
|
|
21
|
+
/** Dropped elements whose content is not markup: skipped to the matching end tag. */
|
|
22
|
+
const RAW_TEXT = new Set(['script', 'style', 'iframe', 'textarea', 'template', 'noscript', 'title'])
|
|
23
|
+
/** Dropped elements with no content. */
|
|
24
|
+
const EMPTY_DROPPED = new Set(['input', 'base', 'link', 'meta', 'embed', 'frame'])
|
|
25
|
+
|
|
26
|
+
const NAMED: Record<string, string> = {
|
|
27
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', colon: ':', Tab: '\t', NewLine: '\n', copy: '©',
|
|
28
|
+
reg: '®', hellip: '…', mdash: '—', ndash: '–', lsquo: '‘', rsquo: '’', ldquo: '“',
|
|
29
|
+
rdquo: '”', bull: '•', middot: '·', euro: '€', zwnj: '', zwj: '', shy: '', trade: '\u2122',
|
|
30
|
+
rarr: '\u2192', larr: '\u2190', times: '\u00d7', deg: '\u00b0', laquo: '\u00ab', raquo: '\u00bb', pound: '\u00a3',
|
|
31
|
+
yen: '\u00a5', cent: '\u00a2', sect: '\u00a7', para: '\u00b6',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Marks an unknown but well-formed named reference in decoded text; `sanitizeHtml` passes it through, `htmlToText` drops it. */
|
|
35
|
+
const REF_OPEN = '\ue000'
|
|
36
|
+
const REF_CLOSE = '\ue001'
|
|
37
|
+
const MARKERS = /[\ue000\ue001]/g
|
|
38
|
+
|
|
39
|
+
/** In text mode an unknown reference like `&foo;` is wrapped in markers instead of left bare. */
|
|
40
|
+
function decodeEntities(s: string, text = false): string {
|
|
41
|
+
if (text) s = s.replace(MARKERS, '')
|
|
42
|
+
if (!s.includes('&')) return s
|
|
43
|
+
return s.replace(/&(#[xX][0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9]*);?/g, (match, body: string) => {
|
|
44
|
+
if (body.startsWith('#')) {
|
|
45
|
+
const code = body[1] === 'x' || body[1] === 'X' ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10)
|
|
46
|
+
if (!Number.isFinite(code) || code <= 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff) || code === 0xe000 || code === 0xe001) return '�'
|
|
47
|
+
return String.fromCodePoint(code)
|
|
48
|
+
}
|
|
49
|
+
const value = match.endsWith(';') ? NAMED[body] : undefined
|
|
50
|
+
if (value !== undefined) return value
|
|
51
|
+
return text && match.endsWith(';') && body.length >= 2 && body.length <= 32 ? `${REF_OPEN}${match}${REF_CLOSE}` : match
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function escapeText(s: string): string {
|
|
56
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Escapes a decoded text run, leaving the marked unknown references as written (they cannot form markup). */
|
|
60
|
+
function escapeRun(s: string): string {
|
|
61
|
+
return escapeText(s).replace(/\ue000&([A-Za-z][A-Za-z0-9]*;)\ue001/g, '&$1').replace(MARKERS, '')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function escapeAttr(s: string): string {
|
|
65
|
+
return escapeText(s).replace(/"/g, '"').replace(/'/g, ''')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Token =
|
|
69
|
+
| { readonly t: 'text'; readonly v: string }
|
|
70
|
+
| { readonly t: 'start'; readonly name: string; readonly attrs: ReadonlyMap<string, string> }
|
|
71
|
+
| { readonly t: 'end'; readonly name: string }
|
|
72
|
+
|
|
73
|
+
interface Tag { readonly end: number; readonly name: string; readonly closing: boolean; readonly selfClosing: boolean; readonly attrs: Map<string, string> }
|
|
74
|
+
|
|
75
|
+
const isSpace = (c: string | undefined): boolean => c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f'
|
|
76
|
+
const isLetter = (c: string | undefined): boolean => c !== undefined && /[A-Za-z]/.test(c)
|
|
77
|
+
|
|
78
|
+
/** Reads the tag starting at `<`; null when it never closes. */
|
|
79
|
+
function readTag(s: string, start: number): Tag | null {
|
|
80
|
+
const n = s.length
|
|
81
|
+
let j = start + 1
|
|
82
|
+
const closing = s[j] === '/'
|
|
83
|
+
if (closing) j++
|
|
84
|
+
const nameStart = j
|
|
85
|
+
while (j < n && !isSpace(s[j]) && s[j] !== '/' && s[j] !== '>') j++
|
|
86
|
+
const name = s.slice(nameStart, j).toLowerCase()
|
|
87
|
+
const attrs = new Map<string, string>()
|
|
88
|
+
for (;;) {
|
|
89
|
+
while (j < n && (isSpace(s[j]) || s[j] === '/')) j++
|
|
90
|
+
if (j >= n) return null
|
|
91
|
+
if (s[j] === '>') return { end: j + 1, name, closing, selfClosing: s[j - 1] === '/', attrs }
|
|
92
|
+
const aStart = j
|
|
93
|
+
while (j < n && !isSpace(s[j]) && s[j] !== '=' && s[j] !== '/' && s[j] !== '>') j++
|
|
94
|
+
if (j === aStart) { j++; continue }
|
|
95
|
+
const attr = s.slice(aStart, j).toLowerCase()
|
|
96
|
+
while (j < n && isSpace(s[j])) j++
|
|
97
|
+
let value = ''
|
|
98
|
+
if (s[j] === '=') {
|
|
99
|
+
j++
|
|
100
|
+
while (j < n && isSpace(s[j])) j++
|
|
101
|
+
const q = s[j]
|
|
102
|
+
if (q === '"' || q === "'") {
|
|
103
|
+
const k = s.indexOf(q, j + 1)
|
|
104
|
+
if (k < 0) return null
|
|
105
|
+
value = s.slice(j + 1, k)
|
|
106
|
+
j = k + 1
|
|
107
|
+
} else {
|
|
108
|
+
const vStart = j
|
|
109
|
+
while (j < n && !isSpace(s[j]) && s[j] !== '>') j++
|
|
110
|
+
value = s.slice(vStart, j)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!attrs.has(attr)) attrs.set(attr, decodeEntities(value))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Tokens of the input with dropped elements and their content already removed. Text and attribute values are decoded. */
|
|
118
|
+
function* tokenize(s: string): Generator<Token> {
|
|
119
|
+
const n = s.length
|
|
120
|
+
let i = 0
|
|
121
|
+
let skip: { name: string; depth: number } | null = null
|
|
122
|
+
while (i < n) {
|
|
123
|
+
if (s[i] !== '<') {
|
|
124
|
+
const k = s.indexOf('<', i)
|
|
125
|
+
const end = k < 0 ? n : k
|
|
126
|
+
if (skip === null) yield { t: 'text', v: decodeEntities(s.slice(i, end), true) }
|
|
127
|
+
i = end
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
const next = s[i + 1]
|
|
131
|
+
if (isLetter(next) || (next === '/' && isLetter(s[i + 2]))) {
|
|
132
|
+
const tag = readTag(s, i)
|
|
133
|
+
if (tag === null) return
|
|
134
|
+
i = tag.end
|
|
135
|
+
if (skip !== null) {
|
|
136
|
+
if (tag.name === skip.name) {
|
|
137
|
+
if (tag.closing) { if (--skip.depth === 0) skip = null } else if (!tag.selfClosing) skip.depth++
|
|
138
|
+
}
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
if (tag.closing) { yield { t: 'end', name: tag.name }; continue }
|
|
142
|
+
if (DROPPED.has(tag.name)) {
|
|
143
|
+
if (EMPTY_DROPPED.has(tag.name) || tag.selfClosing) continue
|
|
144
|
+
if (RAW_TEXT.has(tag.name)) {
|
|
145
|
+
const close = new RegExp(`</${tag.name}[\\s/>]`, 'ig')
|
|
146
|
+
close.lastIndex = i
|
|
147
|
+
const m = close.exec(s)
|
|
148
|
+
if (m === null) return
|
|
149
|
+
const gt = s.indexOf('>', m.index)
|
|
150
|
+
if (gt < 0) return
|
|
151
|
+
i = gt + 1
|
|
152
|
+
} else skip = { name: tag.name, depth: 1 }
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
yield { t: 'start', name: tag.name, attrs: tag.attrs }
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (next === '!' || next === '?' || next === '/') {
|
|
159
|
+
let end: number
|
|
160
|
+
if (s.startsWith('<!-->', i)) end = i + 5
|
|
161
|
+
else if (s.startsWith('<!--->', i)) end = i + 6
|
|
162
|
+
else if (s.startsWith('<!--', i)) { const k = s.indexOf('-->', i + 4); end = k < 0 ? -1 : k + 3 }
|
|
163
|
+
else if (s.startsWith('<![CDATA[', i)) { const k = s.indexOf(']]>', i + 9); end = k < 0 ? -1 : k + 3 }
|
|
164
|
+
else { const k = s.indexOf('>', i + 2); end = k < 0 ? -1 : k + 1 }
|
|
165
|
+
if (end < 0) return
|
|
166
|
+
i = end
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
// A lone '<' is text.
|
|
170
|
+
const k = s.indexOf('<', i + 1)
|
|
171
|
+
const end = k < 0 ? n : k
|
|
172
|
+
if (skip === null) yield { t: 'text', v: decodeEntities(s.slice(i, end), true) }
|
|
173
|
+
i = end
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const cleanUrl = (v: string): string => v.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim()
|
|
178
|
+
const NUM = /^\d{1,5}$/
|
|
179
|
+
const SIZE = /^\d{1,5}%?$/
|
|
180
|
+
const ALIGN = /^(left|right|center|justify)$/i
|
|
181
|
+
const VALIGN = /^(top|middle|bottom|baseline)$/i
|
|
182
|
+
|
|
183
|
+
const ATTRS: Record<string, Record<string, RegExp | 'text'>> = {
|
|
184
|
+
a: { title: 'text' },
|
|
185
|
+
img: { alt: 'text', width: SIZE, height: SIZE, title: 'text' },
|
|
186
|
+
td: { colspan: NUM, rowspan: NUM, align: ALIGN, valign: VALIGN },
|
|
187
|
+
th: { colspan: NUM, rowspan: NUM, align: ALIGN, valign: VALIGN },
|
|
188
|
+
table: { border: NUM, cellpadding: NUM, cellspacing: NUM, width: SIZE, align: ALIGN },
|
|
189
|
+
col: { span: NUM, width: SIZE },
|
|
190
|
+
colgroup: { span: NUM, width: SIZE },
|
|
191
|
+
ol: { start: NUM, type: /^[1aAiI]$/ },
|
|
192
|
+
font: { color: /^#?[0-9A-Za-z]{3,20}$/, size: /^[+-]?\d$/, face: /^[\w ,-]{1,100}$/ },
|
|
193
|
+
p: { align: ALIGN }, div: { align: ALIGN },
|
|
194
|
+
h1: { align: ALIGN }, h2: { align: ALIGN }, h3: { align: ALIGN }, h4: { align: ALIGN }, h5: { align: ALIGN }, h6: { align: ALIGN },
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function safeHref(attrs: ReadonlyMap<string, string>): string | null {
|
|
198
|
+
const href = cleanUrl(attrs.get('href') ?? '')
|
|
199
|
+
return /^(https?:|mailto:)/i.test(href) ? href : null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function serialiseStart(name: string, attrs: ReadonlyMap<string, string>): string {
|
|
203
|
+
let out = `<${name}`
|
|
204
|
+
if (name === 'a') {
|
|
205
|
+
const href = safeHref(attrs)
|
|
206
|
+
if (href !== null) out += ` href="${escapeAttr(href)}"`
|
|
207
|
+
}
|
|
208
|
+
const rules = ATTRS[name]
|
|
209
|
+
if (rules !== undefined) {
|
|
210
|
+
for (const [attr, rule] of Object.entries(rules)) {
|
|
211
|
+
const v = attrs.get(attr)
|
|
212
|
+
if (v === undefined) continue
|
|
213
|
+
if (rule === 'text' || rule.test(v.trim())) out += ` ${attr}="${escapeAttr(rule === 'text' ? v : v.trim())}"`
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (name === 'a') out += ' rel="noopener noreferrer nofollow" target="_blank"'
|
|
217
|
+
return out + '>'
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Returns the HTML reduced to allow-listed tags and attributes: no script, event handlers, style, forms or remote resources. */
|
|
221
|
+
export function sanitizeHtml(html: string): string {
|
|
222
|
+
let out = ''
|
|
223
|
+
const open: string[] = []
|
|
224
|
+
for (const tok of tokenize(html)) {
|
|
225
|
+
if (tok.t === 'text') { out += escapeRun(tok.v); continue }
|
|
226
|
+
if (!ALLOWED.has(tok.name)) continue
|
|
227
|
+
if (tok.t === 'start') {
|
|
228
|
+
if (tok.name === 'img') {
|
|
229
|
+
const src = cleanUrl(tok.attrs.get('src') ?? '')
|
|
230
|
+
if (/^cid:/i.test(src)) {
|
|
231
|
+
out += `<img src="${escapeAttr(src)}"${serialiseStart('img', tok.attrs).slice(4, -1)}>`
|
|
232
|
+
} else {
|
|
233
|
+
const alt = tok.attrs.get('alt') ?? ''
|
|
234
|
+
if (alt.trim() !== '') out += escapeText(alt)
|
|
235
|
+
}
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
out += serialiseStart(tok.name, tok.attrs)
|
|
239
|
+
if (!VOID.has(tok.name)) open.push(tok.name)
|
|
240
|
+
} else {
|
|
241
|
+
const at = open.lastIndexOf(tok.name)
|
|
242
|
+
if (at < 0 || VOID.has(tok.name)) continue
|
|
243
|
+
while (open.length > at) out += `</${open.pop()}>`
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
while (open.length > 0) out += `</${open.pop()}>`
|
|
247
|
+
return out
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const BLOCK = new Set(['p', 'div', 'br', 'li', 'tr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'ul', 'ol', 'table', 'pre', 'hr'])
|
|
251
|
+
|
|
252
|
+
const LINE_START_ONLY = new Set(['li', 'tr', 'br'])
|
|
253
|
+
|
|
254
|
+
/** The readable text of the HTML: block tags become line breaks, entities are decoded, script and style are excluded. */
|
|
255
|
+
export function htmlToText(html: string): string {
|
|
256
|
+
let out = ''
|
|
257
|
+
let pre = 0
|
|
258
|
+
const links: { href: string; at: number }[] = []
|
|
259
|
+
for (const tok of tokenize(html)) {
|
|
260
|
+
if (tok.t === 'text') {
|
|
261
|
+
const v = tok.v.replace(/\ue000[^\ue001]*\ue001/g, '')
|
|
262
|
+
out += pre > 0 ? v : v.replace(/\s+/g, ' ')
|
|
263
|
+
} else if (tok.t === 'start') {
|
|
264
|
+
if (tok.name === 'pre') pre++
|
|
265
|
+
if (tok.name === 'a') {
|
|
266
|
+
const href = safeHref(tok.attrs)
|
|
267
|
+
if (href !== null) links.push({ href, at: out.length })
|
|
268
|
+
}
|
|
269
|
+
if (BLOCK.has(tok.name)) out += '\n'
|
|
270
|
+
} else {
|
|
271
|
+
if (tok.name === 'pre' && pre > 0) pre--
|
|
272
|
+
if (tok.name === 'a') {
|
|
273
|
+
const link = links.pop()
|
|
274
|
+
if (link !== undefined) {
|
|
275
|
+
const label = out.slice(link.at).trim()
|
|
276
|
+
if (label !== '' && label !== link.href && `mailto:${label}` !== link.href) out += ` (${link.href})`
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (BLOCK.has(tok.name) && !LINE_START_ONLY.has(tok.name)) out += '\n'
|
|
280
|
+
else if (tok.name === 'td' || tok.name === 'th') out += ' '
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return out.split('\n').map(l => (pre > 0 ? l : l.replace(/[ \t ]+/g, ' ').trim())).join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
284
|
+
}
|
|
@@ -15,6 +15,7 @@ import type { Handler } from '../../request/di/types.js'
|
|
|
15
15
|
import { __flowStep } from '../../trace/runtime/flow-step.js'
|
|
16
16
|
import { recordBeat, beatsEnabled } from '../alerts/beat.js'
|
|
17
17
|
import type { Mail } from '../email/mail.js'
|
|
18
|
+
import { sanitizeHtml, htmlToText } from '../email/sanitize.js'
|
|
18
19
|
import type { ReceivedEmail } from '../email/inbound.js'
|
|
19
20
|
import { createOutbox, type Sink, type SinkDelivery } from '../outbox/core.js'
|
|
20
21
|
import { TICKET_STATES } from './schema.js'
|
|
@@ -64,7 +65,8 @@ export interface SupportConfig {
|
|
|
64
65
|
/**
|
|
65
66
|
* Decides what `receiveEmail` does with a message. Default: virus verdict `FAIL` drops it; DMARC `FAIL`, or SPF and
|
|
66
67
|
* DKIM both `FAIL`, separates it (the From address is probably forged, so it must not add text to a ticket an agent
|
|
67
|
-
* is reading: it always opens a new ticket); anything else, including no verdicts (the folder stand-in), is accepted.
|
|
68
|
+
* is reading: it always opens a new ticket); anything else, including no verdicts (the folder stand-in), is accepted. A message it
|
|
69
|
+
* separates never names a user: `resolveContact` is not called for it.
|
|
68
70
|
*/
|
|
69
71
|
readonly screen?: (email: ReceivedEmail) => ScreenVerdict
|
|
70
72
|
/**
|
|
@@ -72,8 +74,16 @@ export interface SupportConfig {
|
|
|
72
74
|
* to that tenant; a throw rolls the receive back) when a message has no user of its own: for a new ticket, and for a
|
|
73
75
|
* message joining a ticket whose `user_id` is null, which it fills. A ticket's non-null `user_id` is never changed.
|
|
74
76
|
* `receive` with `contact.userId` does not call it. Null: no known user.
|
|
77
|
+
*
|
|
78
|
+
* `email` is the received message when it came through `receiveEmail` (its `verdicts` say whether the From can be
|
|
79
|
+
* believed) and `null` for `receive` (chat/api). A message `screen` separates (default: DMARC `FAIL`, or SPF and DKIM
|
|
80
|
+
* both `FAIL`) never reaches this hook: its ticket's `user_id` stays null. To also require a pass:
|
|
81
|
+
* `resolveContact: async ({ address, email }, tx) => email !== null && email.verdicts?.dmarc !== 'PASS' ? null : lookUp(address, tx)`.
|
|
75
82
|
*/
|
|
76
|
-
readonly resolveContact?: (
|
|
83
|
+
readonly resolveContact?: (
|
|
84
|
+
contact: { address: string; name: string | null; tenantId: string; email: ReceivedEmail | null },
|
|
85
|
+
tx: DB,
|
|
86
|
+
) => Promise<{ userId: string } | null>
|
|
77
87
|
/**
|
|
78
88
|
* Who may join a ticket by thread. `'contact'` (default): only the ticket's contact; a message from anyone else in the
|
|
79
89
|
* thread opens its own ticket, and `receive` with a `ticketId` refuses a sender who is not the contact. `'conversation'`:
|
|
@@ -278,11 +288,17 @@ function toTicket(r: Record<string, unknown>): Ticket {
|
|
|
278
288
|
}
|
|
279
289
|
}
|
|
280
290
|
|
|
291
|
+
/** Inbound html is stranger-sent: rows stored before sanitising at ingest are cleaned on read (idempotent). */
|
|
292
|
+
function storedHtml(r: Record<string, unknown>): string | null {
|
|
293
|
+
const html = (r['html'] ?? null) as string | null
|
|
294
|
+
return html !== null && r['kind'] === 'inbound' ? sanitizeHtml(html) : html
|
|
295
|
+
}
|
|
296
|
+
|
|
281
297
|
function toMessage(r: Record<string, unknown>): TicketMessage {
|
|
282
298
|
return {
|
|
283
299
|
id: r['id'] as string, ticketId: r['ticket_id'] as string, kind: r['kind'] as TicketMessage['kind'],
|
|
284
300
|
channel: r['channel'] as TicketMessage['channel'], subject: (r['subject'] ?? null) as string | null,
|
|
285
|
-
text: r['text'] as string, html: (r
|
|
301
|
+
text: r['text'] as string, html: storedHtml(r), fromAddress: (r['from_address'] ?? null) as string | null,
|
|
286
302
|
inboundEmailId: (r['inbound_email_id'] ?? null) as string | null,
|
|
287
303
|
attachments: (r['attachments'] ?? null) as TicketMessage['attachments'],
|
|
288
304
|
draftStatus: (r['draft_status'] ?? null) as TicketMessage['draftStatus'], version: Number(r['version']),
|
|
@@ -474,8 +490,10 @@ export function createSupport(config: SupportConfig): Support {
|
|
|
474
490
|
readonly conversationId: string | null
|
|
475
491
|
readonly attachments: unknown
|
|
476
492
|
readonly ticketId: string | null
|
|
477
|
-
/** Never add to an existing ticket. */
|
|
493
|
+
/** Never add to an existing ticket, never name a user. */
|
|
478
494
|
readonly separate?: boolean
|
|
495
|
+
/** The received message; null for chat/api. */
|
|
496
|
+
readonly email?: ReceivedEmail | null
|
|
479
497
|
}
|
|
480
498
|
|
|
481
499
|
async function ingest(flow: Flow, tx: DB, tenantId: string, m: Incoming): Promise<ReceiveResult> {
|
|
@@ -503,8 +521,12 @@ export function createSupport(config: SupportConfig): Support {
|
|
|
503
521
|
if (found[0] !== undefined) ticket = await loadTicket(tx, tenantId, found[0]['id'] as string, true)
|
|
504
522
|
}
|
|
505
523
|
|
|
506
|
-
const resolve = async (): Promise<string | null> =>
|
|
507
|
-
m.userId
|
|
524
|
+
const resolve = async (): Promise<string | null> => {
|
|
525
|
+
if (m.userId !== null) return m.userId
|
|
526
|
+
if (m.separate === true) return null
|
|
527
|
+
const found = await config.resolveContact?.({ address: m.contactEmail, name: m.contactName, tenantId, email: m.email ?? null }, tx)
|
|
528
|
+
return found?.userId ?? null
|
|
529
|
+
}
|
|
508
530
|
|
|
509
531
|
const joinedBy = ticket !== null && ticket.contactEmail !== m.contactEmail ? 'conversation' : null
|
|
510
532
|
let created = false
|
|
@@ -534,9 +556,11 @@ export function createSupport(config: SupportConfig): Support {
|
|
|
534
556
|
ticket = toTicket(rows[0]!)
|
|
535
557
|
}
|
|
536
558
|
|
|
559
|
+
const html = m.html === null ? null : sanitizeHtml(m.html)
|
|
560
|
+
const text = m.text.trim() === '' && m.html !== null ? htmlToText(m.html) : m.text
|
|
537
561
|
await tx.execute(sql`
|
|
538
562
|
INSERT INTO support_message (tenant_id, ticket_id, kind, channel, text, html, from_address, inbound_email_id, attachments)
|
|
539
|
-
VALUES (${tenantId}::uuid, ${ticket.id}::uuid, 'inbound', ${m.channel}, ${
|
|
563
|
+
VALUES (${tenantId}::uuid, ${ticket.id}::uuid, 'inbound', ${m.channel}, ${text}, ${html}, ${m.fromAddress},
|
|
540
564
|
${m.inboundEmailId}::uuid, ${m.attachments === null ? null : JSON.stringify(m.attachments)}::jsonb)`)
|
|
541
565
|
await audit(flow, tx, tenantId, 'receive', ticket.id, { channel: m.channel, emailId: m.inboundEmailId, created, separated: m.separate === true, ...(joinedBy === null ? {} : { joinedBy }), ...(userIdSet === null ? {} : { userIdSet }) })
|
|
542
566
|
await emit(tx, created ? 'ticket.created' : 'ticket.customer-replied', ticket)
|
|
@@ -566,7 +590,7 @@ export function createSupport(config: SupportConfig): Support {
|
|
|
566
590
|
channel: 'email', contactEmail, contactName: email.from.name === '' ? null : email.from.name, userId: null,
|
|
567
591
|
subject: email.subject.trim() === '' ? '(no subject)' : email.subject, text: email.text ?? '', html: email.html,
|
|
568
592
|
fromAddress: contactEmail, inboundEmailId: email.id, conversationId: email.conversationId,
|
|
569
|
-
attachments: attachments.length === 0 ? null : attachments, ticketId: null, separate: verdict === 'separate',
|
|
593
|
+
attachments: attachments.length === 0 ? null : attachments, ticketId: null, separate: verdict === 'separate', email,
|
|
570
594
|
}))
|
|
571
595
|
}
|
|
572
596
|
|