@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.
Files changed (41) hide show
  1. package/dist/styles/qdadm.css +1 -1
  2. package/package.json +8 -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 +31 -11
  10. package/src/components/layout/defaults/DefaultMenu.vue +23 -11
  11. package/src/components/tasks/TaskDialog.vue +95 -0
  12. package/src/components/tasks/TaskHost.vue +45 -0
  13. package/src/components/tasks/TaskTray.vue +54 -0
  14. package/src/composables/index.ts +11 -0
  15. package/src/composables/useListPage.filters.ts +26 -1
  16. package/src/composables/useLogStream.ts +264 -0
  17. package/src/composables/useNavigation.ts +39 -3
  18. package/src/email/index.ts +46 -0
  19. package/src/entity/storage/SdkStorage.ts +53 -1
  20. package/src/entity/storage/index.ts +2 -0
  21. package/src/i18n/defaults/core.en.generated.ts +85 -0
  22. package/src/i18n/defaults/core.en.yml +85 -0
  23. package/src/i18n/defaults/core.fr.generated.ts +85 -0
  24. package/src/i18n/defaults/core.fr.yml +85 -0
  25. package/src/index.ts +21 -0
  26. package/src/kernel/Kernel.vue.ts +9 -2
  27. package/src/kernel/KernelContext.types.ts +11 -1
  28. package/src/module/moduleRegistry.ts +17 -1
  29. package/src/styles/_email.scss +188 -0
  30. package/src/styles/_log-stream.scss +167 -0
  31. package/src/styles/_payload.scss +312 -0
  32. package/src/styles/_sidebar.scss +8 -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,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="&#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'