@quazardous/qdadm 2.31.0 → 2.33.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/dist/styles/qdadm.css +1 -1
- package/package.json +8 -2
- package/src/components/display/EmailPreview.vue +227 -0
- package/src/components/display/EntityTimeline.vue +408 -0
- package/src/components/display/LogStream.vue +364 -0
- package/src/components/display/PayloadDiff.vue +236 -0
- package/src/components/display/PayloadViewer.vue +246 -0
- package/src/components/index.ts +8 -0
- package/src/components/layout/AppLayout.vue +31 -11
- package/src/components/layout/defaults/DefaultMenu.vue +23 -11
- package/src/components/tasks/TaskDialog.vue +95 -0
- package/src/components/tasks/TaskHost.vue +45 -0
- package/src/components/tasks/TaskTray.vue +54 -0
- package/src/composables/index.ts +11 -0
- package/src/composables/useListPage.filters.ts +26 -1
- package/src/composables/useLogStream.ts +264 -0
- package/src/composables/useNavigation.ts +39 -3
- package/src/email/index.ts +46 -0
- package/src/entity/storage/SdkStorage.ts +53 -1
- package/src/entity/storage/index.ts +2 -0
- package/src/i18n/defaults/core.en.generated.ts +85 -0
- package/src/i18n/defaults/core.en.yml +85 -0
- package/src/i18n/defaults/core.fr.generated.ts +85 -0
- package/src/i18n/defaults/core.fr.yml +85 -0
- package/src/index.ts +21 -0
- package/src/kernel/Kernel.vue.ts +9 -2
- package/src/kernel/KernelContext.types.ts +11 -1
- package/src/module/moduleRegistry.ts +17 -1
- package/src/styles/_email.scss +188 -0
- package/src/styles/_log-stream.scss +167 -0
- package/src/styles/_payload.scss +312 -0
- package/src/styles/_sidebar.scss +8 -0
- package/src/styles/_tasks.scss +212 -0
- package/src/styles/_timeline.scss +229 -0
- package/src/styles/index.scss +5 -0
- package/src/tasks/taskStore.ts +460 -0
- package/src/tasks/useTaskRunner.ts +52 -0
- package/src/utils/ansi.ts +127 -0
- package/src/utils/email.ts +270 -0
- package/src/utils/index.ts +32 -0
- package/src/utils/payload.ts +480 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useTaskRunner - start a long backend task and follow it (#4114)
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* const tasks = useTaskRunner()
|
|
6
|
+
* tasks.run({
|
|
7
|
+
* label: `Create box ${name}`,
|
|
8
|
+
* start: () => api.post('/boxes', { name }).then((r) => r.data), // → { task_id }
|
|
9
|
+
* task: { entity: 'tasks' }, // follow the task record
|
|
10
|
+
* log: (id) => ({ poll: (from) => api.get(`/tasks/${id}/log`, { params: { from } }).then((r) => r.data) }),
|
|
11
|
+
* cancel: (id) => api.post(`/tasks/${id}/cancel`),
|
|
12
|
+
* to: (id) => ({ name: 'task-show', params: { id } }),
|
|
13
|
+
* })
|
|
14
|
+
*
|
|
15
|
+
* // As a row action, with a typed confirmation
|
|
16
|
+
* list.addAction('destroy', {
|
|
17
|
+
* icon: 'pi pi-trash', severity: 'danger',
|
|
18
|
+
* onClick: taskAction((box) => ({
|
|
19
|
+
* label: `Destroy ${box.name}`, kind: 'destroy-box',
|
|
20
|
+
* start: () => api.delete(`/boxes/${box.id}`).then((r) => r.data),
|
|
21
|
+
* confirm: { typed: box.name, message: 'The box and its database are deleted.' },
|
|
22
|
+
* })),
|
|
23
|
+
* })
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { inject } from 'vue'
|
|
28
|
+
import { getTaskStore, type RunOptions, type TaskRun } from './taskStore'
|
|
29
|
+
|
|
30
|
+
export function useTaskRunner() {
|
|
31
|
+
const store = getTaskStore()
|
|
32
|
+
store.attach({
|
|
33
|
+
orchestrator: inject('qdadmOrchestrator', null),
|
|
34
|
+
signals: inject('qdadmSignals', null),
|
|
35
|
+
})
|
|
36
|
+
return {
|
|
37
|
+
run: store.run,
|
|
38
|
+
cancel: store.cancel,
|
|
39
|
+
dismiss: store.dismiss,
|
|
40
|
+
open: store.open,
|
|
41
|
+
runs: store.state.runs,
|
|
42
|
+
active: store.active,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* An action handler that starts a task: `onClick: taskAction((row) => ({ label, start, ... }))`.
|
|
48
|
+
* Works for row actions, header actions, show-page actions and plain buttons.
|
|
49
|
+
*/
|
|
50
|
+
export function taskAction<A extends unknown[]>(build: (...args: A) => RunOptions): (...args: A) => Promise<TaskRun | null> {
|
|
51
|
+
return (...args: A) => getTaskStore().run(build(...args))
|
|
52
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI SGR parsing for log output (#4110).
|
|
3
|
+
*
|
|
4
|
+
* Only the Select Graphic Rendition sequences (`ESC [ … m`) carry meaning in
|
|
5
|
+
* a log: colours and weight. Every other escape (cursor moves, erase line,
|
|
6
|
+
* OSC titles) is dropped, since a tail has no cursor to move.
|
|
7
|
+
*
|
|
8
|
+
* The style carries from one line to the next, as in a terminal: a worker
|
|
9
|
+
* that prints `ESC[31m` and two lines later `ESC[0m` coloured both lines.
|
|
10
|
+
* So a stream is parsed in order, passing the state along.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface AnsiStyle {
|
|
14
|
+
fg?: string
|
|
15
|
+
bg?: string
|
|
16
|
+
bold?: boolean
|
|
17
|
+
dim?: boolean
|
|
18
|
+
italic?: boolean
|
|
19
|
+
underline?: boolean
|
|
20
|
+
inverse?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AnsiSegment {
|
|
24
|
+
text: string
|
|
25
|
+
style: AnsiStyle
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** xterm's default 16 colours: 0-7 normal, 8-15 bright. */
|
|
29
|
+
const BASE_COLORS = [
|
|
30
|
+
'#000000', '#cd3131', '#0dbc79', '#e5e510', '#2472c8', '#bc3fbc', '#11a8cd', '#e5e5e5',
|
|
31
|
+
'#666666', '#f14c4c', '#23d18b', '#f5f543', '#3b8eea', '#d670d6', '#29b8db', '#ffffff',
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
function color256(n: number): string | undefined {
|
|
35
|
+
if (n < 0 || n > 255) return undefined
|
|
36
|
+
if (n < 16) return BASE_COLORS[n]
|
|
37
|
+
if (n < 232) {
|
|
38
|
+
const c = n - 16
|
|
39
|
+
const level = (v: number) => (v === 0 ? 0 : 55 + v * 40)
|
|
40
|
+
const r = level(Math.floor(c / 36))
|
|
41
|
+
const g = level(Math.floor(c / 6) % 6)
|
|
42
|
+
const b = level(c % 6)
|
|
43
|
+
return `rgb(${r}, ${g}, ${b})`
|
|
44
|
+
}
|
|
45
|
+
const grey = 8 + (n - 232) * 10
|
|
46
|
+
return `rgb(${grey}, ${grey}, ${grey})`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// eslint-disable-next-line no-control-regex
|
|
50
|
+
const ESCAPE = /\x1b\[([0-9;:]*)([A-Za-z])|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g
|
|
51
|
+
|
|
52
|
+
/** The text of a line without any escape sequence. */
|
|
53
|
+
export function stripAnsi(text: string): string {
|
|
54
|
+
return text.replace(ESCAPE, '')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Apply one SGR parameter list to a style, returning the new style. */
|
|
58
|
+
function applySgr(style: AnsiStyle, params: string): AnsiStyle {
|
|
59
|
+
const codes = params === '' ? [0] : params.split(/[;:]/).map((p) => (p === '' ? 0 : Number(p)))
|
|
60
|
+
let next: AnsiStyle = { ...style }
|
|
61
|
+
for (let i = 0; i < codes.length; i++) {
|
|
62
|
+
const code = codes[i]!
|
|
63
|
+
if (code === 0) next = {}
|
|
64
|
+
else if (code === 1) next.bold = true
|
|
65
|
+
else if (code === 2) next.dim = true
|
|
66
|
+
else if (code === 3) next.italic = true
|
|
67
|
+
else if (code === 4) next.underline = true
|
|
68
|
+
else if (code === 7) next.inverse = true
|
|
69
|
+
else if (code === 22) { delete next.bold; delete next.dim }
|
|
70
|
+
else if (code === 23) delete next.italic
|
|
71
|
+
else if (code === 24) delete next.underline
|
|
72
|
+
else if (code === 27) delete next.inverse
|
|
73
|
+
else if (code >= 30 && code <= 37) next.fg = BASE_COLORS[code - 30]
|
|
74
|
+
else if (code >= 90 && code <= 97) next.fg = BASE_COLORS[code - 90 + 8]
|
|
75
|
+
else if (code === 39) delete next.fg
|
|
76
|
+
else if (code >= 40 && code <= 47) next.bg = BASE_COLORS[code - 40]
|
|
77
|
+
else if (code >= 100 && code <= 107) next.bg = BASE_COLORS[code - 100 + 8]
|
|
78
|
+
else if (code === 49) delete next.bg
|
|
79
|
+
else if (code === 38 || code === 48) {
|
|
80
|
+
const key = code === 38 ? 'fg' : 'bg'
|
|
81
|
+
let value: string | undefined
|
|
82
|
+
if (codes[i + 1] === 5) {
|
|
83
|
+
value = color256(codes[i + 2] ?? -1)
|
|
84
|
+
i += 2
|
|
85
|
+
} else if (codes[i + 1] === 2) {
|
|
86
|
+
const [r, g, b] = [codes[i + 2], codes[i + 3], codes[i + 4]]
|
|
87
|
+
if ([r, g, b].every((v) => v !== undefined && v >= 0 && v <= 255)) value = `rgb(${r}, ${g}, ${b})`
|
|
88
|
+
i += 4
|
|
89
|
+
}
|
|
90
|
+
if (value) next[key] = value
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return next
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Split one line into styled segments, starting from `state` (the style the
|
|
98
|
+
* previous line ended in). Returns the segments and the style this line ends
|
|
99
|
+
* in, for the next one.
|
|
100
|
+
*/
|
|
101
|
+
export function parseAnsiLine(line: string, state: AnsiStyle = {}): { segments: AnsiSegment[]; state: AnsiStyle } {
|
|
102
|
+
const segments: AnsiSegment[] = []
|
|
103
|
+
let style = state
|
|
104
|
+
let last = 0
|
|
105
|
+
ESCAPE.lastIndex = 0
|
|
106
|
+
for (let m = ESCAPE.exec(line); m; m = ESCAPE.exec(line)) {
|
|
107
|
+
if (m.index > last) segments.push({ text: line.slice(last, m.index), style })
|
|
108
|
+
if (m[2] === 'm') style = applySgr(style, m[1] ?? '')
|
|
109
|
+
last = m.index + m[0].length
|
|
110
|
+
}
|
|
111
|
+
if (last < line.length) segments.push({ text: line.slice(last), style })
|
|
112
|
+
return { segments, state: style }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Inline CSS for a style; inverse swaps the colours it has. */
|
|
116
|
+
export function ansiStyleToCss(style: AnsiStyle): Record<string, string> {
|
|
117
|
+
const css: Record<string, string> = {}
|
|
118
|
+
const fg = style.inverse ? style.bg ?? 'var(--qd-log-bg)' : style.fg
|
|
119
|
+
const bg = style.inverse ? style.fg ?? 'var(--qd-log-fg)' : style.bg
|
|
120
|
+
if (fg) css.color = fg
|
|
121
|
+
if (bg) css.backgroundColor = bg
|
|
122
|
+
if (style.bold) css.fontWeight = '700'
|
|
123
|
+
if (style.dim) css.opacity = '0.65'
|
|
124
|
+
if (style.italic) css.fontStyle = 'italic'
|
|
125
|
+
if (style.underline) css.textDecoration = 'underline'
|
|
126
|
+
return css
|
|
127
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email helpers for EmailPreview (#4113).
|
|
3
|
+
*
|
|
4
|
+
* A captured email is shown inside a sandboxed iframe: no scripts, no
|
|
5
|
+
* same-origin access, and a Content-Security-Policy that blocks every
|
|
6
|
+
* remote load (images included, until the user asks) — an email is
|
|
7
|
+
* untrusted HTML written by whoever sent it, and a remote image is a read
|
|
8
|
+
* receipt. Nothing here needs postal-mime; raw MIME parsing lives in
|
|
9
|
+
* `@quazardous/qdadm/email`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface EmailAddress {
|
|
13
|
+
name?: string | null
|
|
14
|
+
address?: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type EmailAddressInput = string | EmailAddress | Array<string | EmailAddress> | null | undefined
|
|
18
|
+
|
|
19
|
+
export interface EmailAttachment {
|
|
20
|
+
filename?: string | null
|
|
21
|
+
contentType?: string | null
|
|
22
|
+
size?: number | null
|
|
23
|
+
/** Where to fetch it (absolute, or relative to the page). */
|
|
24
|
+
url?: string | null
|
|
25
|
+
/** Its bytes, or base64 text. */
|
|
26
|
+
content?: string | ArrayBuffer | Uint8Array | null
|
|
27
|
+
/** `cid:` references in the HTML point here. */
|
|
28
|
+
contentId?: string | null
|
|
29
|
+
disposition?: 'attachment' | 'inline' | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ParsedEmail {
|
|
33
|
+
from?: EmailAddressInput
|
|
34
|
+
to?: EmailAddressInput
|
|
35
|
+
cc?: EmailAddressInput
|
|
36
|
+
replyTo?: EmailAddressInput
|
|
37
|
+
subject?: string | null
|
|
38
|
+
date?: string | Date | null
|
|
39
|
+
messageId?: string | null
|
|
40
|
+
/** Every header, in order. */
|
|
41
|
+
headers?: Array<{ key: string; value: string }> | Record<string, string | string[]> | null
|
|
42
|
+
html?: string | null
|
|
43
|
+
text?: string | null
|
|
44
|
+
/** The raw MIME source, for the Source tab. */
|
|
45
|
+
raw?: string | null
|
|
46
|
+
attachments?: EmailAttachment[] | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Parse a raw MIME message into a ParsedEmail; see `@quazardous/qdadm/email`. */
|
|
50
|
+
export type EmailParser = (raw: string | ArrayBuffer | Uint8Array) => Promise<ParsedEmail>
|
|
51
|
+
|
|
52
|
+
// --- Addresses ---
|
|
53
|
+
|
|
54
|
+
export function addressList(input: EmailAddressInput): EmailAddress[] {
|
|
55
|
+
if (!input) return []
|
|
56
|
+
const list = Array.isArray(input) ? input : [input]
|
|
57
|
+
return list.map((a) => (typeof a === 'string' ? { address: a } : a))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatAddress(a: EmailAddress): string {
|
|
61
|
+
if (a.name && a.address) return `${a.name} <${a.address}>`
|
|
62
|
+
return a.address ?? a.name ?? ''
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function formatAddresses(input: EmailAddressInput): string {
|
|
66
|
+
return addressList(input).map(formatAddress).join(', ')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- Headers ---
|
|
70
|
+
|
|
71
|
+
export function headerList(email: ParsedEmail): Array<{ key: string; value: string }> {
|
|
72
|
+
const h = email.headers
|
|
73
|
+
if (!h) return []
|
|
74
|
+
if (Array.isArray(h)) return h
|
|
75
|
+
return Object.entries(h).flatMap(([key, v]) => (Array.isArray(v) ? v : [v]).map((value) => ({ key, value: String(value) })))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Attachments ---
|
|
79
|
+
|
|
80
|
+
function base64FromBytes(bytes: Uint8Array): string {
|
|
81
|
+
let bin = ''
|
|
82
|
+
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
|
|
83
|
+
return btoa(bin)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toUint8(content: ArrayBuffer | Uint8Array): Uint8Array {
|
|
87
|
+
return ArrayBuffer.isView(content) ? new Uint8Array(content.buffer, content.byteOffset, content.byteLength) : new Uint8Array(content)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A URL for an attachment's bytes: its own URL, or a data URL of its content. */
|
|
91
|
+
export function attachmentUrl(att: EmailAttachment): string | null {
|
|
92
|
+
if (att.url) return att.url
|
|
93
|
+
if (att.content === null || att.content === undefined) return null
|
|
94
|
+
const type = att.contentType || 'application/octet-stream'
|
|
95
|
+
const b64 = typeof att.content === 'string' ? att.content.replace(/\s+/g, '') : base64FromBytes(toUint8(att.content))
|
|
96
|
+
return `data:${type};base64,${b64}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function attachmentSize(att: EmailAttachment): number | null {
|
|
100
|
+
if (typeof att.size === 'number') return att.size
|
|
101
|
+
const c = att.content
|
|
102
|
+
if (!c) return null
|
|
103
|
+
if (typeof c === 'string') return Math.floor((c.replace(/\s+/g, '').length * 3) / 4)
|
|
104
|
+
return toUint8(c).byteLength
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const stripCid = (id: string) => id.trim().replace(/^<|>$/g, '')
|
|
108
|
+
|
|
109
|
+
// --- The HTML document ---
|
|
110
|
+
|
|
111
|
+
/** Remote images an HTML part asks for (src, srcset, background, CSS url()). */
|
|
112
|
+
export function countRemoteImages(html: string): number {
|
|
113
|
+
const matches = html.match(/\b(?:src|background)\s*=\s*["']?\s*(?:https?:)?\/\/|url\(\s*["']?\s*(?:https?:)?\/\//gi)
|
|
114
|
+
return matches?.length ?? 0
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function originOf(url: string): string | null {
|
|
118
|
+
try {
|
|
119
|
+
return new URL(url, typeof location !== 'undefined' ? location.href : 'http://localhost/').origin
|
|
120
|
+
} catch {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface EmailDocumentOptions {
|
|
126
|
+
attachments?: EmailAttachment[] | null
|
|
127
|
+
/** Let remote images load. */
|
|
128
|
+
allowRemoteImages?: boolean
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The iframe's `srcdoc`: the email's HTML behind a CSP that only lets inline
|
|
133
|
+
* styles and data/cid images through, with `cid:` references pointing at
|
|
134
|
+
* their attachments and links opening in a new tab.
|
|
135
|
+
*
|
|
136
|
+
* The CSP is a `<meta>` placed first, so the parser puts it in the head
|
|
137
|
+
* before anything the email itself declares.
|
|
138
|
+
*/
|
|
139
|
+
export function emailDocument(html: string, options: EmailDocumentOptions = {}): string {
|
|
140
|
+
const imgSources = new Set(['data:'])
|
|
141
|
+
const byCid = new Map<string, string>()
|
|
142
|
+
for (const att of options.attachments ?? []) {
|
|
143
|
+
if (!att.contentId) continue
|
|
144
|
+
const url = attachmentUrl(att)
|
|
145
|
+
if (!url) continue
|
|
146
|
+
byCid.set(stripCid(att.contentId).toLowerCase(), url)
|
|
147
|
+
if (!url.startsWith('data:')) {
|
|
148
|
+
const origin = originOf(url)
|
|
149
|
+
if (origin) imgSources.add(origin)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (options.allowRemoteImages) {
|
|
153
|
+
imgSources.add('https:')
|
|
154
|
+
imgSources.add('http:')
|
|
155
|
+
}
|
|
156
|
+
const resolve = (url: string) =>
|
|
157
|
+
url.startsWith('data:') ? url : new URL(url, typeof location !== 'undefined' ? location.href : 'http://localhost/').href
|
|
158
|
+
const withCids = html.replace(/cid:([^"'\s)>]+)/gi, (whole, id: string) => {
|
|
159
|
+
let key = id
|
|
160
|
+
try {
|
|
161
|
+
key = decodeURIComponent(id)
|
|
162
|
+
} catch {
|
|
163
|
+
// a stray % in the id: compare it as written
|
|
164
|
+
}
|
|
165
|
+
const url = byCid.get(stripCid(key).toLowerCase())
|
|
166
|
+
return url ? resolve(url) : whole
|
|
167
|
+
})
|
|
168
|
+
const csp = [
|
|
169
|
+
"default-src 'none'",
|
|
170
|
+
"style-src 'unsafe-inline'",
|
|
171
|
+
`img-src ${[...imgSources].join(' ')}`,
|
|
172
|
+
'font-src data:',
|
|
173
|
+
"form-action 'none'",
|
|
174
|
+
].join('; ')
|
|
175
|
+
const head =
|
|
176
|
+
`<meta http-equiv="Content-Security-Policy" content="${csp}">` +
|
|
177
|
+
'<meta charset="utf-8"><base target="_blank">' +
|
|
178
|
+
'<style>html{color-scheme:light}body{margin:0;padding:12px;font-family:system-ui,sans-serif;background:#fff;color:#222}img{max-width:100%;height:auto}</style>'
|
|
179
|
+
return `<!doctype html>${head}${withoutLeaks(withCids)}`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* What the CSP does not stop: a meta refresh navigates the frame itself to a
|
|
184
|
+
* remote page (the sandbox allows a frame to navigate itself), DNS prefetch /
|
|
185
|
+
* preconnect hints reach out before any policy applies, and a `<base>` would
|
|
186
|
+
* re-aim every relative link.
|
|
187
|
+
*
|
|
188
|
+
* Read with DOMParser, whose documents are inert (nothing runs, nothing
|
|
189
|
+
* loads): the browser decodes attributes the way the frame would, so
|
|
190
|
+
* `http-equiv="refresh"` is caught too. A regex is the fallback where
|
|
191
|
+
* there is no DOM.
|
|
192
|
+
*/
|
|
193
|
+
function withoutLeaks(html: string): string {
|
|
194
|
+
if (typeof DOMParser === 'undefined') {
|
|
195
|
+
return html
|
|
196
|
+
.replace(/<meta\b[^>]*http-equiv[^>]*>/gi, '')
|
|
197
|
+
.replace(/<link\b[^>]*>/gi, '')
|
|
198
|
+
.replace(/<base\b[^>]*>/gi, '')
|
|
199
|
+
}
|
|
200
|
+
const doc = new DOMParser().parseFromString(html, 'text/html')
|
|
201
|
+
const HINTS = ['dns-prefetch', 'preconnect', 'prefetch', 'prerender', 'preload', 'modulepreload']
|
|
202
|
+
doc.querySelectorAll('meta[http-equiv], base').forEach((el) => el.remove())
|
|
203
|
+
doc.querySelectorAll('link').forEach((el) => {
|
|
204
|
+
const rel = (el.getAttribute('rel') ?? '').toLowerCase().split(/\s+/)
|
|
205
|
+
if (rel.some((r) => HINTS.includes(r)) || rel.includes('stylesheet') || rel.includes('import')) el.remove()
|
|
206
|
+
})
|
|
207
|
+
return doc.documentElement.outerHTML
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The iframe sandbox: no scripts, an opaque origin, links may open a new tab. */
|
|
211
|
+
export const EMAIL_SANDBOX = 'allow-popups allow-popups-to-escape-sandbox'
|
|
212
|
+
|
|
213
|
+
// --- Mailpit ---
|
|
214
|
+
|
|
215
|
+
interface MailpitAddress {
|
|
216
|
+
Name?: string
|
|
217
|
+
Address?: string
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
interface MailpitPart {
|
|
221
|
+
PartID: string
|
|
222
|
+
FileName?: string
|
|
223
|
+
ContentType?: string
|
|
224
|
+
ContentID?: string
|
|
225
|
+
Size?: number
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface MailpitMessage {
|
|
229
|
+
ID: string
|
|
230
|
+
From?: MailpitAddress | null
|
|
231
|
+
To?: MailpitAddress[] | null
|
|
232
|
+
Cc?: MailpitAddress[] | null
|
|
233
|
+
ReplyTo?: MailpitAddress[] | null
|
|
234
|
+
Subject?: string
|
|
235
|
+
Date?: string
|
|
236
|
+
MessageID?: string
|
|
237
|
+
HTML?: string
|
|
238
|
+
Text?: string
|
|
239
|
+
Attachments?: MailpitPart[] | null
|
|
240
|
+
Inline?: MailpitPart[] | null
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A Mailpit message (`GET /api/v1/message/{ID}`) as a ParsedEmail. Parts are
|
|
245
|
+
* fetched from `${baseUrl}/api/v1/message/{ID}/part/{PartID}`; headers come
|
|
246
|
+
* from `/headers`, and the source from `/raw`, if the app wants them.
|
|
247
|
+
*/
|
|
248
|
+
export function fromMailpit(message: MailpitMessage, baseUrl = ''): ParsedEmail {
|
|
249
|
+
const addr = (a?: MailpitAddress | null): EmailAddress => ({ name: a?.Name || null, address: a?.Address || null })
|
|
250
|
+
const part = (p: MailpitPart, disposition: 'attachment' | 'inline'): EmailAttachment => ({
|
|
251
|
+
filename: p.FileName ?? null,
|
|
252
|
+
contentType: p.ContentType ?? null,
|
|
253
|
+
size: p.Size ?? null,
|
|
254
|
+
contentId: p.ContentID ?? null,
|
|
255
|
+
url: `${baseUrl.replace(/\/$/, '')}/api/v1/message/${encodeURIComponent(message.ID)}/part/${encodeURIComponent(p.PartID)}`,
|
|
256
|
+
disposition,
|
|
257
|
+
})
|
|
258
|
+
return {
|
|
259
|
+
from: message.From ? addr(message.From) : null,
|
|
260
|
+
to: (message.To ?? []).map(addr),
|
|
261
|
+
cc: (message.Cc ?? []).map(addr),
|
|
262
|
+
replyTo: (message.ReplyTo ?? []).map(addr),
|
|
263
|
+
subject: message.Subject ?? null,
|
|
264
|
+
date: message.Date ?? null,
|
|
265
|
+
messageId: message.MessageID ?? null,
|
|
266
|
+
html: message.HTML ?? null,
|
|
267
|
+
text: message.Text ?? null,
|
|
268
|
+
attachments: [...(message.Attachments ?? []).map((p) => part(p, 'attachment')), ...(message.Inline ?? []).map((p) => part(p, 'inline'))],
|
|
269
|
+
}
|
|
270
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -40,5 +40,37 @@ export {
|
|
|
40
40
|
isEqual,
|
|
41
41
|
} from './transformers'
|
|
42
42
|
|
|
43
|
+
// ANSI escapes in logs (#4110)
|
|
44
|
+
export { parseAnsiLine, stripAnsi, ansiStyleToCss, type AnsiStyle, type AnsiSegment } from './ansi'
|
|
45
|
+
|
|
46
|
+
// Payloads in their own format (#4112)
|
|
47
|
+
export {
|
|
48
|
+
detectFormat,
|
|
49
|
+
maskKeys,
|
|
50
|
+
prettyXml,
|
|
51
|
+
parseCsv,
|
|
52
|
+
parseForm,
|
|
53
|
+
diffLines,
|
|
54
|
+
diffCsv,
|
|
55
|
+
normalizeLines,
|
|
56
|
+
type PayloadFormat,
|
|
57
|
+
type PayloadBody,
|
|
58
|
+
type PayloadMask,
|
|
59
|
+
type DiffOp,
|
|
60
|
+
} from './payload'
|
|
61
|
+
|
|
62
|
+
// Captured emails (#4113); raw MIME parsing is in '@quazardous/qdadm/email'
|
|
63
|
+
export {
|
|
64
|
+
emailDocument,
|
|
65
|
+
fromMailpit,
|
|
66
|
+
formatAddresses,
|
|
67
|
+
EMAIL_SANDBOX,
|
|
68
|
+
type ParsedEmail,
|
|
69
|
+
type EmailAddress,
|
|
70
|
+
type EmailAttachment,
|
|
71
|
+
type EmailParser,
|
|
72
|
+
type MailpitMessage,
|
|
73
|
+
} from './email'
|
|
74
|
+
|
|
43
75
|
// Type exports
|
|
44
76
|
export type { KeyValuePair, LanguageObject } from './transformers'
|