@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.
Files changed (41) hide show
  1. package/dist/styles/qdadm.css +1 -1
  2. package/package.json +9 -2
  3. package/src/components/display/EmailPreview.vue +227 -0
  4. package/src/components/display/EntityTimeline.vue +408 -0
  5. package/src/components/display/LogStream.vue +364 -0
  6. package/src/components/display/PayloadDiff.vue +236 -0
  7. package/src/components/display/PayloadViewer.vue +246 -0
  8. package/src/components/index.ts +8 -0
  9. package/src/components/layout/AppLayout.vue +8 -0
  10. package/src/components/tasks/TaskDialog.vue +95 -0
  11. package/src/components/tasks/TaskHost.vue +45 -0
  12. package/src/components/tasks/TaskTray.vue +54 -0
  13. package/src/composables/index.ts +11 -0
  14. package/src/composables/useListPage.filters.ts +26 -1
  15. package/src/composables/useLogStream.ts +264 -0
  16. package/src/email/index.ts +46 -0
  17. package/src/entity/storage/SdkStorage.ts +53 -1
  18. package/src/entity/storage/index.ts +2 -0
  19. package/src/i18n/defaults/core.en.generated.ts +85 -0
  20. package/src/i18n/defaults/core.en.yml +85 -0
  21. package/src/i18n/defaults/core.fr.generated.ts +85 -0
  22. package/src/i18n/defaults/core.fr.yml +85 -0
  23. package/src/index.ts +21 -0
  24. package/src/kernel/Kernel.vue.ts +9 -2
  25. package/src/kernel/Module.ts +3 -2
  26. package/src/modules/debug/DebugModule.ts +3 -1
  27. package/src/modules/debug/styles.generated.css +2895 -0
  28. package/src/notifications/NotificationModule.ts +1 -1
  29. package/src/notifications/{styles.scss → styles.css} +5 -5
  30. package/src/styles/_email.scss +188 -0
  31. package/src/styles/_log-stream.scss +167 -0
  32. package/src/styles/_payload.scss +312 -0
  33. package/src/styles/_tasks.scss +212 -0
  34. package/src/styles/_timeline.scss +229 -0
  35. package/src/styles/index.scss +5 -0
  36. package/src/tasks/taskStore.ts +460 -0
  37. package/src/tasks/useTaskRunner.ts +52 -0
  38. package/src/utils/ansi.ts +127 -0
  39. package/src/utils/email.ts +270 -0
  40. package/src/utils/index.ts +32 -0
  41. 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="&#x72;efresh"` 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
+ }
@@ -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'