@quazardous/qdadm 2.32.0 → 2.33.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/dist/styles/qdadm.css +1 -1
- package/package.json +9 -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 +8 -0
- 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/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/Module.ts +3 -2
- package/src/modules/debug/DebugModule.ts +3 -1
- package/src/modules/debug/styles.generated.css +2895 -0
- package/src/notifications/NotificationModule.ts +1 -1
- package/src/notifications/{styles.scss → styles.css} +5 -5
- 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/_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,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'
|