@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.
@@ -10,12 +10,18 @@ export default new Action({
10
10
 
11
11
  async handle() {
12
12
  try {
13
- const messages = await listCapturedMail()
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('rejects malformed capture files instead of fabricating metadata', async () => {
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
- await expect(listCapturedMail(directory)).rejects.toThrow('missing its log-driver header')
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((email, index) => {
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
- export async function listCapturedMail(directory = capturedMailDirectory()): Promise<CapturedMailSummary[]> {
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 => diskMessage(name, directory)),
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), ...memoryMessages()]) {
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 messages.sort((left, right) =>
206
- new Date(right.sentAt).getTime() - new Date(left.sentAt).getTime(),
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> {
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.72.78",
5
+ "version": "0.72.79",
6
6
  "description": "A modern Stacks development environment.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
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.78",
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.78",
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>