pipework 0.253.0 → 0.255.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 +12 -0
- package/dist/infra/alerts/handle.d.ts +10 -0
- package/dist/infra/alerts/handle.d.ts.map +1 -1
- package/dist/infra/alerts/handle.js +9 -2
- package/dist/infra/alerts/handle.js.map +1 -1
- package/dist/infra/email/inbound.d.ts +17 -1
- package/dist/infra/email/inbound.d.ts.map +1 -1
- package/dist/infra/email/inbound.js +27 -2
- 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/email/schema.d.ts +5 -0
- package/dist/infra/email/schema.d.ts.map +1 -1
- package/dist/infra/email/schema.js +4 -0
- package/dist/infra/email/schema.js.map +1 -1
- package/dist/infra/email/sns.js +1 -1
- package/dist/infra/email/sns.js.map +1 -1
- package/dist/infra/outbox/core.d.ts +24 -0
- package/dist/infra/outbox/core.d.ts.map +1 -1
- package/dist/infra/outbox/core.js +58 -0
- package/dist/infra/outbox/core.js.map +1 -1
- package/dist/infra/support/handle.d.ts +14 -0
- package/dist/infra/support/handle.d.ts.map +1 -1
- package/dist/infra/support/handle.js +26 -5
- 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/alerts/handle.ts +16 -2
- package/src/infra/email/inbound.ts +44 -3
- 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/email/schema.ts +4 -0
- package/src/infra/email/sns.ts +1 -1
- package/src/infra/outbox/core.ts +70 -0
- package/src/infra/support/handle.ts +37 -5
|
@@ -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
|
+
}
|
|
@@ -9,6 +9,7 @@ import { define } from '../../data/domain/index.js'
|
|
|
9
9
|
import { field } from '../../data/domain/field.js'
|
|
10
10
|
import { schema } from '../../data/schema/namespace.js'
|
|
11
11
|
import { index, unique } from '../../data/db/idx/index.js'
|
|
12
|
+
import { sql } from '../../data/db/sql.js'
|
|
12
13
|
|
|
13
14
|
export const EMAIL_TABLE_NAMES: ReadonlySet<string> = new Set([
|
|
14
15
|
'pipework_email_messages', 'pipework_email_suppressions', 'pipework_email_inbound', 'pipework_email_unsubscribes',
|
|
@@ -71,6 +72,8 @@ export const emailInbound = define('pipework_email_inbound', {
|
|
|
71
72
|
rawLocation: field.jsonb(schema.check.json()),
|
|
72
73
|
rawExpiresAt: field.timestamp(),
|
|
73
74
|
rawDeletedAt: field.timestamp().nullable(),
|
|
75
|
+
// Set when `sweep` nulls the address, subject, recipients and Message-ID after `metadataRetentionDays`.
|
|
76
|
+
metadataScrubbedAt: field.timestamp().nullable(),
|
|
74
77
|
status: field.enum(INBOUND_STATUSES).default('received'),
|
|
75
78
|
error: field.text().nullable(),
|
|
76
79
|
messageIdHeader: field.text().nullable(),
|
|
@@ -92,5 +95,6 @@ export const emailInbound = define('pipework_email_inbound', {
|
|
|
92
95
|
bySource: unique('pipework_email_inbound_source_id_key').on(t['sourceId']),
|
|
93
96
|
byMessageId: index('idx_pipework_email_inbound_message_id').on(t['messageIdHeader']),
|
|
94
97
|
byExpiry: index('idx_pipework_email_inbound_raw_expires').on(t['rawExpiresAt']),
|
|
98
|
+
unscrubbed: index('idx_pipework_email_inbound_unscrubbed').on(t['receivedAt']).where(sql.raw('metadata_scrubbed_at IS NULL')),
|
|
95
99
|
}),
|
|
96
100
|
})
|
package/src/infra/email/sns.ts
CHANGED
|
@@ -94,7 +94,7 @@ const processEvent = __flowStep({ ref: 'pipework/email:notification' }, async (e
|
|
|
94
94
|
} else {
|
|
95
95
|
getBaseLogger().info({ event: 'email bounce not suppressed', address, bounceType: r.bounceType }, 'transient bounce: address kept')
|
|
96
96
|
}
|
|
97
|
-
await auditEmail(r.kind, messageId, {
|
|
97
|
+
await auditEmail(r.kind, messageId, { bounceType: r.bounceType, suppressed: permanent, messageTraceId })
|
|
98
98
|
}
|
|
99
99
|
})
|
|
100
100
|
|
package/src/infra/outbox/core.ts
CHANGED
|
@@ -13,6 +13,9 @@ const LEASE_MS = 30_000
|
|
|
13
13
|
const BATCH = 50
|
|
14
14
|
const MAX_BATCHES = 20
|
|
15
15
|
const MAX_BACKOFF_MS = 3_600_000
|
|
16
|
+
const SWEEP_BATCH = 500
|
|
17
|
+
const DEFAULT_SWEEP_LIMIT = 10_000
|
|
18
|
+
const SWEEP_EVERY_MS = 3_600_000
|
|
16
19
|
|
|
17
20
|
export interface OutboxTotals {
|
|
18
21
|
delivered: number
|
|
@@ -61,6 +64,27 @@ export interface OutboxOptions<D> {
|
|
|
61
64
|
readonly onGivenUp: (row: Record<string, unknown>, attempt: number, error: string) => Promise<void>
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
/** Retention for an outbox's rows, in days. Unset: kept. */
|
|
68
|
+
export interface OutboxRetention {
|
|
69
|
+
/** Delivered rows (and their payloads) older than this are deleted. */
|
|
70
|
+
readonly deliveredRetentionDays: number
|
|
71
|
+
/** Given-up rows (`failed_at` set, never delivered) older than this are deleted. Unset: kept. */
|
|
72
|
+
readonly failedRetentionDays?: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Refuses a retention that is not whole days of zero or more, naming `who` and the option. */
|
|
76
|
+
export function checkRetention(who: string, config: { readonly deliveredRetentionDays?: number; readonly failedRetentionDays?: number }): OutboxRetention | undefined {
|
|
77
|
+
for (const name of ['deliveredRetentionDays', 'failedRetentionDays'] as const) {
|
|
78
|
+
const v = config[name]
|
|
79
|
+
if (v !== undefined && !(Number.isInteger(v) && v >= 0)) throw new Error(`[pipework] ${who}: ${name} must be an integer of zero or more, got ${String(v)}.`)
|
|
80
|
+
}
|
|
81
|
+
if (config.failedRetentionDays !== undefined && config.deliveredRetentionDays === undefined) {
|
|
82
|
+
throw new Error(`[pipework] ${who}: failedRetentionDays needs deliveredRetentionDays too.`)
|
|
83
|
+
}
|
|
84
|
+
if (config.deliveredRetentionDays === undefined) return undefined
|
|
85
|
+
return { deliveredRetentionDays: config.deliveredRetentionDays, ...(config.failedRetentionDays !== undefined ? { failedRetentionDays: config.failedRetentionDays } : {}) }
|
|
86
|
+
}
|
|
87
|
+
|
|
64
88
|
export interface Outbox {
|
|
65
89
|
/** Delivers what is due now, up to 20 batches; rows that fail wait for the next call. */
|
|
66
90
|
drain(): Promise<OutboxTotals>
|
|
@@ -70,6 +94,26 @@ export interface Outbox {
|
|
|
70
94
|
* `prev_attempts`, the attempts it had when given up.
|
|
71
95
|
*/
|
|
72
96
|
requeue(options: { sink?: string; failedSince?: Date }): Promise<Record<string, unknown>[]>
|
|
97
|
+
/**
|
|
98
|
+
* Deletes delivered rows whose `delivered_at` is older than `deliveredRetentionDays` and, with `failedRetentionDays`,
|
|
99
|
+
* given-up rows whose `failed_at` is older than that. A row still queued or being retried is never deleted.
|
|
100
|
+
* Deletes in batches of 500, at most `limit` rows per call (default 10 000).
|
|
101
|
+
*/
|
|
102
|
+
sweep(options: OutboxRetention & { limit?: number }): Promise<{ deleted: number }>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A function that runs `outbox.sweep` with `retention` at most once an hour per process, and never throws; `onError` gets a failure. No retention: does nothing. */
|
|
106
|
+
export function hourlySweep(outbox: Outbox, retention: OutboxRetention | undefined, onError: (err: unknown) => void): () => Promise<void> {
|
|
107
|
+
let last = 0
|
|
108
|
+
return async () => {
|
|
109
|
+
if (retention === undefined || Date.now() - last < SWEEP_EVERY_MS) return
|
|
110
|
+
last = Date.now()
|
|
111
|
+
try {
|
|
112
|
+
await outbox.sweep(retention)
|
|
113
|
+
} catch (err) {
|
|
114
|
+
onError(err)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
73
117
|
}
|
|
74
118
|
|
|
75
119
|
function withTimeout(promise: Promise<void>, ms: number): Promise<void> {
|
|
@@ -136,7 +180,33 @@ export function createOutbox<D>(options: OutboxOptions<D>): Outbox {
|
|
|
136
180
|
}))
|
|
137
181
|
}
|
|
138
182
|
|
|
183
|
+
async function deleteBatch(predicate: ReturnType<typeof sql>, size: number): Promise<number> {
|
|
184
|
+
const rows = await db().execute(sql`
|
|
185
|
+
DELETE FROM ${table} WHERE id IN (SELECT id FROM ${table} WHERE ${predicate} LIMIT ${size}) RETURNING id`)
|
|
186
|
+
return rows.length
|
|
187
|
+
}
|
|
188
|
+
|
|
139
189
|
return {
|
|
190
|
+
async sweep(opts) {
|
|
191
|
+
const days = [['deliveredRetentionDays', opts.deliveredRetentionDays], ['failedRetentionDays', opts.failedRetentionDays], ['limit', opts.limit]] as const
|
|
192
|
+
for (const [name, v] of days) {
|
|
193
|
+
if (v !== undefined && !(Number.isInteger(v) && v >= (name === 'limit' ? 1 : 0))) {
|
|
194
|
+
throw new ValidationError({ [name]: ['must be a whole number' + (name === 'limit' ? ' of one or more' : ' of zero or more')] }, `Outbox sweep ${name} is invalid.`)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const delivered = sql`delivered_at IS NOT NULL AND delivered_at < clock_timestamp() - ${opts.deliveredRetentionDays} * interval '1 day'`
|
|
198
|
+
const failed = opts.failedRetentionDays === undefined ? null
|
|
199
|
+
: sql`delivered_at IS NULL AND failed_at IS NOT NULL AND failed_at < clock_timestamp() - ${opts.failedRetentionDays} * interval '1 day'`
|
|
200
|
+
const predicate = failed === null ? delivered : sql`((${delivered}) OR (${failed}))`
|
|
201
|
+
const limit = opts.limit ?? DEFAULT_SWEEP_LIMIT
|
|
202
|
+
let deleted = 0
|
|
203
|
+
while (deleted < limit) {
|
|
204
|
+
const n = await deleteBatch(predicate, Math.min(SWEEP_BATCH, limit - deleted))
|
|
205
|
+
deleted += n
|
|
206
|
+
if (n === 0) break
|
|
207
|
+
}
|
|
208
|
+
return { deleted }
|
|
209
|
+
},
|
|
140
210
|
async requeue(opts) {
|
|
141
211
|
if (opts.failedSince !== undefined && !(opts.failedSince instanceof Date && !Number.isNaN(opts.failedSince.getTime()))) {
|
|
142
212
|
throw new ValidationError({ failedSince: ['must be a valid Date'] }, 'Outbox failedSince must be a Date.')
|
|
@@ -15,8 +15,9 @@ 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
|
-
import { createOutbox, type Sink, type SinkDelivery } from '../outbox/core.js'
|
|
20
|
+
import { checkRetention, createOutbox, hourlySweep, type Sink, type SinkDelivery } from '../outbox/core.js'
|
|
20
21
|
import { TICKET_STATES } from './schema.js'
|
|
21
22
|
import { describeError } from '../../core/logging/error-chain.js'
|
|
22
23
|
|
|
@@ -98,6 +99,14 @@ export interface SupportConfig {
|
|
|
98
99
|
readonly maxAttempts?: number
|
|
99
100
|
/** First retry delay, doubling per attempt up to one hour; default 5000. */
|
|
100
101
|
readonly backoffMs?: number
|
|
102
|
+
/**
|
|
103
|
+
* Days a delivered event row, with its payload (the ticket subject), is kept before the sweep deletes it. Unset: kept.
|
|
104
|
+
* `tick` (so `support.job`) runs the sweep at most once an hour per process; `sweep()` runs it on demand.
|
|
105
|
+
* The ticket audit rows carry ids only; they expire with the trace partitions (`auditRetentionDays`).
|
|
106
|
+
*/
|
|
107
|
+
readonly deliveredRetentionDays?: number
|
|
108
|
+
/** Days an event row that was given up on (never delivered) is kept. Needs `deliveredRetentionDays`. Unset: kept. */
|
|
109
|
+
readonly failedRetentionDays?: number
|
|
101
110
|
}
|
|
102
111
|
|
|
103
112
|
export interface Ticket {
|
|
@@ -255,6 +264,8 @@ export interface Support extends WithFor<TicketApi> {
|
|
|
255
264
|
readonly job: Handler<Record<string, unknown>, unknown>
|
|
256
265
|
/** What one run of `job` does; callable from a test. Needs a Flow. */
|
|
257
266
|
tick(): Promise<{ delivered: number; retried: number; failed: number }>
|
|
267
|
+
/** Deletes event rows past `deliveredRetentionDays` / `failedRetentionDays` now, up to `limit` (default 10 000); `deleted: 0` when neither is set. Needs a Flow. */
|
|
268
|
+
sweep(options?: { limit?: number }): Promise<{ deleted: number }>
|
|
258
269
|
/**
|
|
259
270
|
* Puts the event deliveries that were given up (and never delivered) back on the queue, for every tenant, optionally
|
|
260
271
|
* only those of `sink` and/or that failed at or after `failedSince`; the next `tick` sends them. Delivery ids stay the
|
|
@@ -287,11 +298,17 @@ function toTicket(r: Record<string, unknown>): Ticket {
|
|
|
287
298
|
}
|
|
288
299
|
}
|
|
289
300
|
|
|
301
|
+
/** Inbound html is stranger-sent: rows stored before sanitising at ingest are cleaned on read (idempotent). */
|
|
302
|
+
function storedHtml(r: Record<string, unknown>): string | null {
|
|
303
|
+
const html = (r['html'] ?? null) as string | null
|
|
304
|
+
return html !== null && r['kind'] === 'inbound' ? sanitizeHtml(html) : html
|
|
305
|
+
}
|
|
306
|
+
|
|
290
307
|
function toMessage(r: Record<string, unknown>): TicketMessage {
|
|
291
308
|
return {
|
|
292
309
|
id: r['id'] as string, ticketId: r['ticket_id'] as string, kind: r['kind'] as TicketMessage['kind'],
|
|
293
310
|
channel: r['channel'] as TicketMessage['channel'], subject: (r['subject'] ?? null) as string | null,
|
|
294
|
-
text: r['text'] as string, html: (r
|
|
311
|
+
text: r['text'] as string, html: storedHtml(r), fromAddress: (r['from_address'] ?? null) as string | null,
|
|
295
312
|
inboundEmailId: (r['inbound_email_id'] ?? null) as string | null,
|
|
296
313
|
attachments: (r['attachments'] ?? null) as TicketMessage['attachments'],
|
|
297
314
|
draftStatus: (r['draft_status'] ?? null) as TicketMessage['draftStatus'], version: Number(r['version']),
|
|
@@ -351,6 +368,7 @@ export function createSupport(config: SupportConfig): Support {
|
|
|
351
368
|
if ((joinThread as string) !== 'contact' && (joinThread as string) !== 'conversation') {
|
|
352
369
|
throw new Error(`[pipework] support.create: joinThread must be 'contact' or 'conversation', got ${JSON.stringify(joinThread)}.`)
|
|
353
370
|
}
|
|
371
|
+
const retention = checkRetention('support.create', config)
|
|
354
372
|
const jobType = config.jobType ?? 'pipework.support.events'
|
|
355
373
|
const replyTemplate = config.replyTemplate ?? 'pipework.support.reply'
|
|
356
374
|
const distinctApprover = config.distinctApprover ?? true
|
|
@@ -538,7 +556,7 @@ export function createSupport(config: SupportConfig): Support {
|
|
|
538
556
|
RETURNING *`)
|
|
539
557
|
ticket = toTicket(rows[0]!)
|
|
540
558
|
created = true
|
|
541
|
-
await audit(flow, tx, tenantId, 'create', ticket.id, {
|
|
559
|
+
await audit(flow, tx, tenantId, 'create', ticket.id, { channel: m.channel, userId: ticket.userId })
|
|
542
560
|
} else {
|
|
543
561
|
await discardLive(flow, tx, ticket, 'customer-replied')
|
|
544
562
|
if (ticket.state !== 'open') ticket = await moveTo(flow, tx, ticket, 'open', true)
|
|
@@ -549,9 +567,11 @@ export function createSupport(config: SupportConfig): Support {
|
|
|
549
567
|
ticket = toTicket(rows[0]!)
|
|
550
568
|
}
|
|
551
569
|
|
|
570
|
+
const html = m.html === null ? null : sanitizeHtml(m.html)
|
|
571
|
+
const text = m.text.trim() === '' && m.html !== null ? htmlToText(m.html) : m.text
|
|
552
572
|
await tx.execute(sql`
|
|
553
573
|
INSERT INTO support_message (tenant_id, ticket_id, kind, channel, text, html, from_address, inbound_email_id, attachments)
|
|
554
|
-
VALUES (${tenantId}::uuid, ${ticket.id}::uuid, 'inbound', ${m.channel}, ${
|
|
574
|
+
VALUES (${tenantId}::uuid, ${ticket.id}::uuid, 'inbound', ${m.channel}, ${text}, ${html}, ${m.fromAddress},
|
|
555
575
|
${m.inboundEmailId}::uuid, ${m.attachments === null ? null : JSON.stringify(m.attachments)}::jsonb)`)
|
|
556
576
|
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 }) })
|
|
557
577
|
await emit(tx, created ? 'ticket.created' : 'ticket.customer-replied', ticket)
|
|
@@ -793,7 +813,15 @@ export function createSupport(config: SupportConfig): Support {
|
|
|
793
813
|
await audit(flow, null, payload.tenantId, verb, row['ticket_id'] as string, { sink: row['sink'], kind: row['kind'], ...extra })
|
|
794
814
|
}
|
|
795
815
|
|
|
796
|
-
const
|
|
816
|
+
const sweepIfDue = hourlySweep(outbox, retention, (err) => {
|
|
817
|
+
getBaseLogger().error({ err }, 'support event sweep failed')
|
|
818
|
+
void recordFault('support-event-sweep', faultMessage(err), {})
|
|
819
|
+
})
|
|
820
|
+
const drainStep = __flowStep({ ref: 'pipework/support:tick' }, async () => {
|
|
821
|
+
const totals = await outbox.drain()
|
|
822
|
+
await sweepIfDue()
|
|
823
|
+
return totals
|
|
824
|
+
})
|
|
797
825
|
// A beat for `alerts.source.loopStalled`, written only where the blueprint has `alerts`.
|
|
798
826
|
const beat = async (outcome: { ok: boolean; error?: string }): Promise<void> => {
|
|
799
827
|
const ctx = getContext()
|
|
@@ -846,6 +874,10 @@ export function createSupport(config: SupportConfig): Support {
|
|
|
846
874
|
},
|
|
847
875
|
job: job as unknown as Handler<Record<string, unknown>, unknown>,
|
|
848
876
|
tick: () => tickStep(),
|
|
877
|
+
async sweep(options = {}) {
|
|
878
|
+
requireContext()
|
|
879
|
+
return retention === undefined ? { deleted: 0 } : outbox.sweep({ ...retention, ...(options.limit !== undefined ? { limit: options.limit } : {}) })
|
|
880
|
+
},
|
|
849
881
|
async requeueFailed(options) {
|
|
850
882
|
requireContext()
|
|
851
883
|
nonEmpty('by', options.by)
|