@stacksjs/defaults 0.72.78 → 0.72.79
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/app/Actions/Dashboard/Email/CapturedMailIndexAction.ts +7 -1
- package/app/Actions/Dashboard/Email/captured-mail.test.ts +54 -4
- package/app/Actions/Dashboard/Email/captured-mail.ts +102 -40
- package/functions/captured-mail.ts +9 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/Dashboard/Email/CapturedMailDashboard.stx +28 -1
|
@@ -10,12 +10,18 @@ export default new Action({
|
|
|
10
10
|
|
|
11
11
|
async handle() {
|
|
12
12
|
try {
|
|
13
|
-
|
|
13
|
+
// `problems` carries the captures that could not be parsed. They are
|
|
14
|
+
// reported rather than thrown so one stale file cannot 503 the inbox,
|
|
15
|
+
// and reported rather than dropped so the operator can still see that
|
|
16
|
+
// something in the capture directory needs attention.
|
|
17
|
+
const { messages, problems } = await listCapturedMail()
|
|
14
18
|
return {
|
|
15
19
|
captureDriver: 'log',
|
|
16
20
|
activeDriver: process.env.MAIL_MAILER || 'log',
|
|
17
21
|
total: messages.length,
|
|
18
22
|
messages,
|
|
23
|
+
unreadable: problems.length,
|
|
24
|
+
problems,
|
|
19
25
|
}
|
|
20
26
|
}
|
|
21
27
|
catch (error) {
|
|
@@ -24,7 +24,7 @@ afterEach(async () => {
|
|
|
24
24
|
describe('captured dashboard mail', () => {
|
|
25
25
|
it('returns an empty list when the capture directory does not exist', async () => {
|
|
26
26
|
const root = await temporaryDirectory()
|
|
27
|
-
expect(await listCapturedMail(join(root, 'missing'))).toEqual([])
|
|
27
|
+
expect(await listCapturedMail(join(root, 'missing'))).toEqual({ messages: [], problems: [] })
|
|
28
28
|
})
|
|
29
29
|
|
|
30
30
|
it('lists and reads a valid disk capture', async () => {
|
|
@@ -41,7 +41,8 @@ describe('captured dashboard mail', () => {
|
|
|
41
41
|
`
|
|
42
42
|
await writeFile(join(directory, filename), html)
|
|
43
43
|
|
|
44
|
-
const messages = await listCapturedMail(directory)
|
|
44
|
+
const { messages, problems } = await listCapturedMail(directory)
|
|
45
|
+
expect(problems).toEqual([])
|
|
45
46
|
expect(messages).toHaveLength(1)
|
|
46
47
|
expect(messages[0]).toMatchObject({
|
|
47
48
|
id: `disk:${filename}`,
|
|
@@ -62,10 +63,59 @@ describe('captured dashboard mail', () => {
|
|
|
62
63
|
expect(message?.text).toBe('')
|
|
63
64
|
})
|
|
64
65
|
|
|
65
|
-
it('
|
|
66
|
+
it('reports a malformed capture instead of fabricating metadata for it', async () => {
|
|
66
67
|
const directory = await temporaryDirectory()
|
|
67
68
|
await writeFile(join(directory, 'broken.html'), '<p>No capture header</p>')
|
|
68
|
-
|
|
69
|
+
|
|
70
|
+
const { messages, problems } = await listCapturedMail(directory)
|
|
71
|
+
expect(messages).toEqual([])
|
|
72
|
+
expect(problems).toEqual([
|
|
73
|
+
{ capture: 'broken.html', reason: expect.stringContaining('missing its log-driver header') },
|
|
74
|
+
])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('still lists the readable captures when one file alongside them is broken', async () => {
|
|
78
|
+
// The bug this pins: `storage/logs/mail` held 29 captures, one of them
|
|
79
|
+
// written before the header format settled, and the single bad file made
|
|
80
|
+
// the whole endpoint 503 - the dashboard inbox showed nothing at all.
|
|
81
|
+
const directory = await temporaryDirectory()
|
|
82
|
+
const good = '2026-07-29T12-00-00-000Z-Welcome.html'
|
|
83
|
+
await writeFile(join(directory, good), `<!--
|
|
84
|
+
Captured by @stacksjs/email log driver at 2026-07-29T12:00:00.000Z
|
|
85
|
+
From: Stacks <hello@example.com>
|
|
86
|
+
To: Chris <chris@example.com>
|
|
87
|
+
Subject: Welcome
|
|
88
|
+
-->
|
|
89
|
+
<main><p>Your account is ready.</p></main>
|
|
90
|
+
`)
|
|
91
|
+
await writeFile(join(directory, 'legacy-no-header.html'), '<p>Written before the header existed</p>')
|
|
92
|
+
await writeFile(join(directory, 'headers-missing-subject.html'), `<!--
|
|
93
|
+
Captured by @stacksjs/email log driver at 2026-07-29T12:00:00.000Z
|
|
94
|
+
From: Stacks <hello@example.com>
|
|
95
|
+
To: Chris <chris@example.com>
|
|
96
|
+
-->
|
|
97
|
+
<main><p>No subject line.</p></main>
|
|
98
|
+
`)
|
|
99
|
+
|
|
100
|
+
const { messages, problems } = await listCapturedMail(directory)
|
|
101
|
+
|
|
102
|
+
expect(messages).toHaveLength(1)
|
|
103
|
+
expect(messages[0]?.id).toBe(`disk:${good}`)
|
|
104
|
+
expect(problems.map(problem => problem.capture)).toEqual([
|
|
105
|
+
'headers-missing-subject.html',
|
|
106
|
+
'legacy-no-header.html',
|
|
107
|
+
])
|
|
108
|
+
expect(problems[0]?.reason).toContain('missing required message headers')
|
|
109
|
+
expect(problems[1]?.reason).toContain('missing its log-driver header')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('still throws when a broken capture is the one being asked for by id', async () => {
|
|
113
|
+
// Listing skips it; fetching it by id must not answer with an empty
|
|
114
|
+
// result, because there the broken file IS what the caller asked about.
|
|
115
|
+
const directory = await temporaryDirectory()
|
|
116
|
+
await writeFile(join(directory, 'broken.html'), '<p>No capture header</p>')
|
|
117
|
+
await expect(showCapturedMail('disk:broken.html', directory))
|
|
118
|
+
.rejects.toThrow('missing its log-driver header')
|
|
69
119
|
})
|
|
70
120
|
|
|
71
121
|
it('rejects disk ids that could escape the capture directory', async () => {
|
|
@@ -25,6 +25,17 @@ export interface CapturedMailMessage extends CapturedMailSummary {
|
|
|
25
25
|
text: string
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export interface CapturedMailProblem {
|
|
29
|
+
/** The capture that could not be read: a disk filename, or `mem:<index>`. */
|
|
30
|
+
capture: string
|
|
31
|
+
reason: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CapturedMailListing {
|
|
35
|
+
messages: CapturedMailSummary[]
|
|
36
|
+
problems: CapturedMailProblem[]
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
function capturedMailDirectory(): string {
|
|
29
40
|
return process.env.LOG_MAIL_DIR || logsPath('mail')
|
|
30
41
|
}
|
|
@@ -65,6 +76,10 @@ function validTimestamp(value: string, source: string): string {
|
|
|
65
76
|
return new Date(time).toISOString()
|
|
66
77
|
}
|
|
67
78
|
|
|
79
|
+
function reasonFor(error: unknown): string {
|
|
80
|
+
return error instanceof Error ? error.message : String(error)
|
|
81
|
+
}
|
|
82
|
+
|
|
68
83
|
function headerValue(header: string, label: string): string {
|
|
69
84
|
const match = header.match(new RegExp(`^\\s*${label}:\\s*(.*)$`, 'im'))
|
|
70
85
|
return match?.[1]?.trim() || ''
|
|
@@ -123,48 +138,69 @@ async function diskMessage(name: string, directory: string): Promise<CapturedMai
|
|
|
123
138
|
}
|
|
124
139
|
}
|
|
125
140
|
|
|
141
|
+
type CapturedLogEmail = ReturnType<typeof LogEmailDriver.captured>[number]
|
|
142
|
+
|
|
143
|
+
function memoryMessage(email: CapturedLogEmail, index: number): CapturedMailMessage {
|
|
144
|
+
if (!(email.sentAt instanceof Date) || !Number.isFinite(email.sentAt.getTime()))
|
|
145
|
+
throw new TypeError('Captured in-memory email contains an invalid timestamp.')
|
|
146
|
+
if (typeof email.subject !== 'string' || !email.subject.trim())
|
|
147
|
+
throw new TypeError('Captured in-memory email is missing its subject.')
|
|
148
|
+
|
|
149
|
+
const html = email.rendered?.html ?? email.html ?? ''
|
|
150
|
+
const text = email.rendered?.text ?? email.text ?? ''
|
|
151
|
+
if (typeof html !== 'string' || typeof text !== 'string')
|
|
152
|
+
throw new TypeError('Captured in-memory email bodies must be strings.')
|
|
153
|
+
|
|
154
|
+
const from = formatAddress(email.from)
|
|
155
|
+
const to = formatAddress(email.to)
|
|
156
|
+
if (!from || !to)
|
|
157
|
+
throw new TypeError('Captured in-memory email is missing its sender or recipient.')
|
|
158
|
+
|
|
159
|
+
const sentAt = email.sentAt.toISOString()
|
|
160
|
+
return {
|
|
161
|
+
id: `mem:${email.sentAt.getTime()}:${index}`,
|
|
162
|
+
source: 'memory',
|
|
163
|
+
from,
|
|
164
|
+
to,
|
|
165
|
+
cc: formatAddress(email.cc),
|
|
166
|
+
bcc: formatAddress(email.bcc),
|
|
167
|
+
subject: email.subject,
|
|
168
|
+
preview: previewFor(html || text),
|
|
169
|
+
sentAt,
|
|
170
|
+
hasHtml: Boolean(html),
|
|
171
|
+
hasText: Boolean(text),
|
|
172
|
+
size: Buffer.byteLength(html, 'utf8') + Buffer.byteLength(text, 'utf8'),
|
|
173
|
+
html,
|
|
174
|
+
text,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
126
178
|
function memoryMessages(): CapturedMailMessage[] {
|
|
127
|
-
return LogEmailDriver.captured().map(
|
|
128
|
-
if (!(email.sentAt instanceof Date) || !Number.isFinite(email.sentAt.getTime()))
|
|
129
|
-
throw new TypeError('Captured in-memory email contains an invalid timestamp.')
|
|
130
|
-
if (typeof email.subject !== 'string' || !email.subject.trim())
|
|
131
|
-
throw new TypeError('Captured in-memory email is missing its subject.')
|
|
132
|
-
|
|
133
|
-
const html = email.rendered?.html ?? email.html ?? ''
|
|
134
|
-
const text = email.rendered?.text ?? email.text ?? ''
|
|
135
|
-
if (typeof html !== 'string' || typeof text !== 'string')
|
|
136
|
-
throw new TypeError('Captured in-memory email bodies must be strings.')
|
|
137
|
-
|
|
138
|
-
const from = formatAddress(email.from)
|
|
139
|
-
const to = formatAddress(email.to)
|
|
140
|
-
if (!from || !to)
|
|
141
|
-
throw new TypeError('Captured in-memory email is missing its sender or recipient.')
|
|
142
|
-
|
|
143
|
-
const sentAt = email.sentAt.toISOString()
|
|
144
|
-
return {
|
|
145
|
-
id: `mem:${email.sentAt.getTime()}:${index}`,
|
|
146
|
-
source: 'memory',
|
|
147
|
-
from,
|
|
148
|
-
to,
|
|
149
|
-
cc: formatAddress(email.cc),
|
|
150
|
-
bcc: formatAddress(email.bcc),
|
|
151
|
-
subject: email.subject,
|
|
152
|
-
preview: previewFor(html || text),
|
|
153
|
-
sentAt,
|
|
154
|
-
hasHtml: Boolean(html),
|
|
155
|
-
hasText: Boolean(text),
|
|
156
|
-
size: Buffer.byteLength(html, 'utf8') + Buffer.byteLength(text, 'utf8'),
|
|
157
|
-
html,
|
|
158
|
-
text,
|
|
159
|
-
}
|
|
160
|
-
})
|
|
179
|
+
return LogEmailDriver.captured().map(memoryMessage)
|
|
161
180
|
}
|
|
162
181
|
|
|
163
182
|
function dedupeKey(message: CapturedMailSummary): string {
|
|
164
183
|
return `${message.sentAt}|${message.from}|${message.to}|${message.subject}`
|
|
165
184
|
}
|
|
166
185
|
|
|
167
|
-
|
|
186
|
+
/**
|
|
187
|
+
* List every capture the log mail driver has taken.
|
|
188
|
+
*
|
|
189
|
+
* One unreadable capture must not take down the listing. A stale or
|
|
190
|
+
* hand-edited file in the capture directory is an ordinary condition, and
|
|
191
|
+
* failing the whole request over it returned a 503 that hid every other
|
|
192
|
+
* captured email - on a real directory of 29 files, a single one written
|
|
193
|
+
* before the header format settled was enough to empty the inbox.
|
|
194
|
+
*
|
|
195
|
+
* Skipping it silently would be the other wrong answer, so the capture is
|
|
196
|
+
* reported in `problems` and the dashboard says what it could not read.
|
|
197
|
+
* Nothing is fabricated for it, which is what the strict parse was for.
|
|
198
|
+
*
|
|
199
|
+
* `showCapturedMail` still throws for a capture the caller asked for by id:
|
|
200
|
+
* there the broken file IS the answer, so the reason belongs in the response
|
|
201
|
+
* rather than behind an empty result.
|
|
202
|
+
*/
|
|
203
|
+
export async function listCapturedMail(directory = capturedMailDirectory()): Promise<CapturedMailListing> {
|
|
168
204
|
let names: string[]
|
|
169
205
|
try {
|
|
170
206
|
names = await readdir(directory)
|
|
@@ -176,12 +212,33 @@ export async function listCapturedMail(directory = capturedMailDirectory()): Pro
|
|
|
176
212
|
throw error
|
|
177
213
|
}
|
|
178
214
|
|
|
215
|
+
const problems: CapturedMailProblem[] = []
|
|
216
|
+
|
|
179
217
|
const disk = await Promise.all(
|
|
180
|
-
names.filter(name => name.endsWith('.html')).map(name =>
|
|
218
|
+
names.filter(name => name.endsWith('.html')).map(async (name) => {
|
|
219
|
+
try {
|
|
220
|
+
return await diskMessage(name, directory)
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
problems.push({ capture: name, reason: reasonFor(error) })
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
226
|
+
}),
|
|
181
227
|
)
|
|
228
|
+
|
|
229
|
+
const memory: CapturedMailMessage[] = []
|
|
230
|
+
LogEmailDriver.captured().forEach((email, index) => {
|
|
231
|
+
try {
|
|
232
|
+
memory.push(memoryMessage(email, index))
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
problems.push({ capture: `mem:${index}`, reason: reasonFor(error) })
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
182
239
|
const seen = new Set<string>()
|
|
183
240
|
const messages: CapturedMailSummary[] = []
|
|
184
|
-
for (const message of [...disk.filter((entry): entry is CapturedMailMessage => entry !== null), ...
|
|
241
|
+
for (const message of [...disk.filter((entry): entry is CapturedMailMessage => entry !== null), ...memory]) {
|
|
185
242
|
const key = dedupeKey(message)
|
|
186
243
|
if (seen.has(key))
|
|
187
244
|
continue
|
|
@@ -202,9 +259,14 @@ export async function listCapturedMail(directory = capturedMailDirectory()): Pro
|
|
|
202
259
|
})
|
|
203
260
|
}
|
|
204
261
|
|
|
205
|
-
return
|
|
206
|
-
|
|
207
|
-
|
|
262
|
+
return {
|
|
263
|
+
messages: messages.sort((left, right) =>
|
|
264
|
+
new Date(right.sentAt).getTime() - new Date(left.sentAt).getTime(),
|
|
265
|
+
),
|
|
266
|
+
// Settled in filename order rather than whichever read rejected first, so
|
|
267
|
+
// the same directory always produces the same response.
|
|
268
|
+
problems: problems.sort((left, right) => left.capture.localeCompare(right.capture)),
|
|
269
|
+
}
|
|
208
270
|
}
|
|
209
271
|
|
|
210
272
|
export async function showCapturedMail(id: string, directory = capturedMailDirectory()): Promise<CapturedMailMessage | null> {
|
|
@@ -20,11 +20,20 @@ export interface CapturedMailMessage extends CapturedMailSummary {
|
|
|
20
20
|
text: string
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export interface CapturedMailProblem {
|
|
24
|
+
/** The capture that could not be read: a disk filename, or `mem:<index>`. */
|
|
25
|
+
capture: string
|
|
26
|
+
reason: string
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
export interface CapturedMailIndex {
|
|
24
30
|
captureDriver: 'log'
|
|
25
31
|
activeDriver: string
|
|
26
32
|
total: number
|
|
27
33
|
messages: CapturedMailSummary[]
|
|
34
|
+
/** How many captures were skipped because they could not be parsed. */
|
|
35
|
+
unreadable: number
|
|
36
|
+
problems: CapturedMailProblem[]
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
export async function fetchCapturedMail(): Promise<CapturedMailIndex> {
|
package/ide/vscode/package.json
CHANGED
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.72.
|
|
5
|
+
"version": "0.72.79",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@iconify-json/f7": "^1.2.2",
|
|
53
53
|
"@iconify-json/hugeicons": "^1.2.27",
|
|
54
|
-
"@stacksjs/mobile": "^0.72.
|
|
54
|
+
"@stacksjs/mobile": "^0.72.79",
|
|
55
55
|
"@stacksjs/sanitizer": "^0.2.113"
|
|
56
56
|
},
|
|
57
57
|
"scripts": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script client>
|
|
2
|
-
import type { CapturedMailMessage, CapturedMailSummary } from '../../../../functions/captured-mail'
|
|
2
|
+
import type { CapturedMailMessage, CapturedMailProblem, CapturedMailSummary } from '../../../../functions/captured-mail'
|
|
3
3
|
import { fetchCapturedMail, fetchCapturedMailMessage } from '../../../../functions/captured-mail'
|
|
4
4
|
import { pushToast } from '../../../../functions/toasts'
|
|
5
5
|
|
|
@@ -7,12 +7,22 @@ const messages = state<CapturedMailSummary[]>([])
|
|
|
7
7
|
const selectedId = state('')
|
|
8
8
|
const selectedMessage = state<CapturedMailMessage | null>(null)
|
|
9
9
|
const activeDriver = state('')
|
|
10
|
+
const unreadable = state<CapturedMailProblem[]>([])
|
|
10
11
|
const loading = state(true)
|
|
11
12
|
const detailLoading = state(false)
|
|
12
13
|
const error = state('')
|
|
13
14
|
const detailError = state('')
|
|
14
15
|
let selectionVersion = 0
|
|
15
16
|
|
|
17
|
+
// The thrown reason already names the file, and the row prints it in mono
|
|
18
|
+
// right before this, so the prefix would read the filename out twice.
|
|
19
|
+
function problemReason(problem: CapturedMailProblem): string {
|
|
20
|
+
const prefix = `Captured mail file ${problem.capture} is `
|
|
21
|
+
return problem.reason.startsWith(prefix)
|
|
22
|
+
? problem.reason.slice(prefix.length)
|
|
23
|
+
: problem.reason
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
function eventValue<T>(value: T | CustomEvent<T>): T {
|
|
17
27
|
return value instanceof CustomEvent ? value.detail : value
|
|
18
28
|
}
|
|
@@ -50,6 +60,9 @@ async function loadCapturedMail(): Promise<void> {
|
|
|
50
60
|
const result = await fetchCapturedMail()
|
|
51
61
|
messages.set(result.messages)
|
|
52
62
|
activeDriver.set(result.activeDriver)
|
|
63
|
+
// A capture the server could not parse is skipped rather than failing the
|
|
64
|
+
// whole listing, so it has to be said out loud here or it disappears.
|
|
65
|
+
unreadable.set(result.problems ?? [])
|
|
53
66
|
|
|
54
67
|
const current = result.messages.find(message => message.id === selectedId())
|
|
55
68
|
const next = current || result.messages[0]
|
|
@@ -67,6 +80,7 @@ async function loadCapturedMail(): Promise<void> {
|
|
|
67
80
|
const message = cause instanceof Error ? cause.message : 'Captured mail could not be loaded.'
|
|
68
81
|
error.set(message)
|
|
69
82
|
messages.set([])
|
|
83
|
+
unreadable.set([])
|
|
70
84
|
selectedId.set('')
|
|
71
85
|
selectedMessage.set(null)
|
|
72
86
|
pushToast('error', 'Could not load captured mail', { detail: message })
|
|
@@ -92,6 +106,19 @@ onMount(() => {
|
|
|
92
106
|
<p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">
|
|
93
107
|
Outbound messages recorded by the local log driver. Active mailer: {{ activeDriver() || 'log' }}.
|
|
94
108
|
</p>
|
|
109
|
+
<div :if="unreadable().length > 0" class="mt-2 flex gap-2 items-start px-3 py-2 bg-amber-50 border border-amber-200 rounded-md text-amber-800 text-xs dark:bg-amber-900/20 dark:border-amber-800/40 dark:text-amber-200">
|
|
110
|
+
<span class="mt-0.5 h-3.5 w-3.5 shrink-0 i-hugeicons-alert-02"></span>
|
|
111
|
+
<div>
|
|
112
|
+
<p class="font-medium">
|
|
113
|
+
{{ unreadable().length }} capture{{ unreadable().length === 1 ? '' : 's' }} could not be read and {{ unreadable().length === 1 ? 'was' : 'were' }} left out of this list.
|
|
114
|
+
</p>
|
|
115
|
+
<ul class="mt-1 space-y-0.5">
|
|
116
|
+
<template :for="problem in unreadable()">
|
|
117
|
+
<li class="break-all"><span class="font-mono">{{ problem.capture }}</span> is {{ problemReason(problem) }}</li>
|
|
118
|
+
</template>
|
|
119
|
+
</ul>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
95
122
|
</div>
|
|
96
123
|
<Button variant="secondary" :loading="loading()" @click="loadCapturedMail">
|
|
97
124
|
<span :if="!loading()" class="h-4 w-4 i-hugeicons-refresh"></span>
|