@stacksjs/defaults 0.70.354 → 0.70.356
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/ai/skills/stacks-browse/SKILL.md +17 -2
- package/ai/skills/stacks-browse/scripts/browse.ts +207 -6
- package/ai/skills/stacks-email/SKILL.md +73 -16
- package/app/Actions/ContactAction.ts +2 -2
- package/app/Actions/Dashboard/Analytics/EventAnalyticsAction.ts +2 -3
- package/app/Actions/Dashboard/Analytics/MarketingAnalyticsAction.ts +2 -3
- package/app/Actions/Dashboard/Analytics/SalesAnalyticsAction.ts +2 -3
- package/app/Actions/Dashboard/Analytics/WebAnalyticsAction.ts +9 -3
- package/app/Actions/Dashboard/Auth/MeAction.ts +12 -14
- package/app/Actions/Dashboard/Buddy/BuddyChatAction.ts +2 -3
- package/app/Actions/Dashboard/Ci/RepoRunJobsAction.ts +10 -15
- package/app/Actions/Dashboard/Ci/RepoRunsAction.ts +12 -14
- package/app/Actions/Dashboard/Ci/RunnerHistoryAction.ts +11 -10
- package/app/Actions/Dashboard/Ci/StatusAction.ts +2 -11
- package/app/Actions/Dashboard/Cloud/CloudIndexAction.ts +8 -1
- package/app/Actions/Dashboard/Cloud/ServerlessIndexAction.ts +15 -8
- package/app/Actions/Dashboard/Commerce/CommerceCategoriesAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceCouponsAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceCustomersAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceDashboardAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceDeliveryAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceGiftCardsAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceOrdersAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommercePaymentsAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommercePosAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +76 -61
- package/app/Actions/Dashboard/Commerce/CommercePrintDevicesAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommercePrintLogsAction.ts +4 -7
- package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +4 -4
- package/app/Actions/Dashboard/Commerce/CommerceProductsAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/CommerceTaxesAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/DeliveryRouteIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/DigitalDeliveryIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/DriverIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/LicenseKeyIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/LicenseKeyOptionsAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ManufacturerIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/PaymentRefundAction.ts +1 -0
- package/app/Actions/Dashboard/Commerce/ProductUnitDefaultAction.ts +1 -0
- package/app/Actions/Dashboard/Commerce/ProductUnitIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ProductVariantIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ProductWaitlistIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/RestaurantWaitlistIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ReviewIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ShippingMethodIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ShippingRateIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/ShippingZoneIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Commerce/TaxRateDefaultAction.ts +1 -0
- package/app/Actions/Dashboard/DashboardActivityAction.ts +2 -5
- package/app/Actions/Dashboard/Data/ActivityIndexAction.ts +2 -5
- package/app/Actions/Dashboard/Data/SubscriberIndexAction.ts +2 -5
- package/app/Actions/Dashboard/Data/TeamIndexAction.ts +2 -5
- package/app/Actions/Dashboard/Data/UserIndexAction.ts +2 -5
- package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +26 -21
- package/app/Actions/Dashboard/Deployments/GetAverageDeploymentTime.ts +8 -2
- package/app/Actions/Dashboard/Deployments/GetDeployScript.ts +3 -6
- package/app/Actions/Dashboard/Deployments/GetDeployment.ts +5 -5
- package/app/Actions/Dashboard/Deployments/GetDeploymentCount.ts +7 -1
- package/app/Actions/Dashboard/Deployments/GetDeploymentLiveTerminalOutput.ts +10 -9
- package/app/Actions/Dashboard/Deployments/GetDeployments.ts +2 -4
- package/app/Actions/Dashboard/Deployments/GetRecentDeployments.ts +8 -2
- package/app/Actions/Dashboard/Deployments/UpdateDeployScript.ts +21 -7
- package/app/Actions/Dashboard/Email/CapturedMailIndexAction.ts +2 -4
- package/app/Actions/Dashboard/Email/CapturedMailShowAction.ts +3 -4
- package/app/Actions/Dashboard/Email/InboxActivityAction.ts +28 -16
- package/app/Actions/Dashboard/Email/InboxAttachmentDownloadAction.ts +45 -0
- package/app/Actions/Dashboard/Email/InboxDeleteAction.ts +11 -6
- package/app/Actions/Dashboard/Email/InboxIndexAction.ts +5 -7
- package/app/Actions/Dashboard/Email/InboxMarkReadAction.ts +11 -6
- package/app/Actions/Dashboard/Email/InboxMarkUnreadAction.ts +11 -6
- package/app/Actions/Dashboard/Email/InboxPreferenceShowAction.ts +10 -4
- package/app/Actions/Dashboard/Email/InboxPreferenceUpdateAction.ts +8 -9
- package/app/Actions/Dashboard/Email/InboxShowAction.ts +7 -7
- package/app/Actions/Dashboard/Email/InboxStatsAction.ts +5 -7
- package/app/Actions/Dashboard/Email/inbox-request.ts +33 -0
- package/app/Actions/Dashboard/Infrastructure/InsightsAction.ts +15 -14
- package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +4 -11
- package/app/Actions/Dashboard/Infrastructure/ServerShowAction.ts +3 -6
- package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +9 -18
- package/app/Actions/Dashboard/Jobs/JobRetryAction.ts +4 -3
- package/app/Actions/Dashboard/Jobs/JobShowAction.ts +16 -11
- package/app/Actions/Dashboard/Jobs/JobStatsAction.ts +2 -4
- package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +7 -8
- package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +11 -19
- package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +6 -6
- package/app/Actions/Dashboard/Kanban/BoardUpdateAction.ts +7 -8
- package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +3 -5
- package/app/Actions/Dashboard/Kanban/BoardsReorderAction.ts +6 -6
- package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +13 -12
- package/app/Actions/Dashboard/Kanban/CardCommentDestroyAction.ts +12 -15
- package/app/Actions/Dashboard/Kanban/CardCommentStoreAction.ts +21 -46
- package/app/Actions/Dashboard/Kanban/CardCommentUpdateAction.ts +43 -0
- package/app/Actions/Dashboard/Kanban/CardDestroyAction.ts +7 -8
- package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +8 -9
- package/app/Actions/Dashboard/Kanban/CardShowAction.ts +6 -7
- package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +9 -9
- package/app/Actions/Dashboard/Kanban/CardUpdateAction.ts +7 -8
- package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +6 -6
- package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +7 -8
- package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +6 -6
- package/app/Actions/Dashboard/Kanban/ColumnUpdateAction.ts +7 -8
- package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +6 -6
- package/app/Actions/Dashboard/Kanban/LabelDestroyAction.ts +6 -7
- package/app/Actions/Dashboard/Kanban/LabelStoreAction.ts +6 -6
- package/app/Actions/Dashboard/Kanban/LabelUpdateAction.ts +7 -8
- package/app/Actions/Dashboard/Kanban/UsersListAction.ts +3 -4
- package/app/Actions/Dashboard/Kanban/kanban-comment.ts +14 -0
- package/app/Actions/Dashboard/Kanban/kanban-model.test.ts +36 -0
- package/app/Actions/Dashboard/Kanban/kanban-model.ts +14 -7
- package/app/Actions/Dashboard/Kanban/kanban-response.ts +5 -0
- package/app/Actions/Dashboard/Library/CreateComponent.ts +15 -6
- package/app/Actions/Dashboard/Library/CreateFunction.ts +15 -6
- package/app/Actions/Dashboard/Library/DependencyIndexAction.ts +88 -77
- package/app/Actions/Dashboard/Library/GetAverageReleaseTime.ts +21 -15
- package/app/Actions/Dashboard/Library/GetComponents.ts +14 -7
- package/app/Actions/Dashboard/Library/GetDownloadCount.ts +14 -5
- package/app/Actions/Dashboard/Library/GetFunctions.ts +12 -5
- package/app/Actions/Dashboard/Library/GetReleaseCount.ts +14 -5
- package/app/Actions/Dashboard/Library/GetReleases.ts +17 -10
- package/app/Actions/Dashboard/Library/PackageIndexAction.ts +14 -7
- package/app/Actions/Dashboard/Models/ModelDestroyAction.ts +11 -8
- package/app/Actions/Dashboard/Models/ModelShowAction.ts +97 -192
- package/app/Actions/Dashboard/Models/ModelStoreAction.ts +14 -12
- package/app/Actions/Dashboard/Models/ModelUpdateAction.ts +18 -16
- package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +50 -19
- package/app/Actions/Dashboard/Models/model-api.ts +27 -0
- package/app/Actions/Dashboard/Models/model-write.ts +22 -15
- package/app/Actions/Dashboard/Notifications/NotificationDeliveryHistoryAction.ts +49 -40
- package/app/Actions/Dashboard/Notifications/NotificationDeliveryIndexAction.ts +20 -10
- package/app/Actions/Dashboard/Notifications/NotificationDeliveryOverviewAction.ts +96 -84
- package/app/Actions/Dashboard/Notifications/NotificationDeliveryRetryAction.ts +34 -21
- package/app/Actions/Dashboard/Queries/QueryDashboardAction.ts +2 -4
- package/app/Actions/Dashboard/Queries/QueryShowAction.ts +3 -3
- package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +2 -4
- package/app/Actions/Dashboard/Queue/QueueWorkersAction.ts +2 -4
- package/app/Actions/Dashboard/Rbac/PermissionDestroyAction.ts +6 -6
- package/app/Actions/Dashboard/Rbac/PermissionStoreAction.ts +5 -4
- package/app/Actions/Dashboard/Rbac/PermissionsIndexAction.ts +2 -3
- package/app/Actions/Dashboard/Rbac/RoleDestroyAction.ts +6 -6
- package/app/Actions/Dashboard/Rbac/RolePermissionsShowAction.ts +6 -6
- package/app/Actions/Dashboard/Rbac/RolePermissionsSyncAction.ts +6 -9
- package/app/Actions/Dashboard/Rbac/RoleStoreAction.ts +5 -4
- package/app/Actions/Dashboard/Rbac/RolesIndexAction.ts +4 -6
- package/app/Actions/Dashboard/Rbac/UserRolesShowAction.ts +6 -7
- package/app/Actions/Dashboard/Rbac/UserRolesSyncAction.ts +6 -13
- package/app/Actions/Dashboard/Rbac/UsersListAction.ts +2 -3
- package/app/Actions/Dashboard/Rbac/rbac-response.ts +10 -0
- package/app/Actions/Dashboard/Realtime/RealtimeStatsAction.ts +2 -4
- package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +69 -55
- package/app/Actions/Dashboard/Teams/TeamInvitationDestroyAction.ts +34 -16
- package/app/Actions/Dashboard/Teams/TeamInvitationResendAction.ts +52 -29
- package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +104 -73
- package/app/Actions/Dashboard/Teams/TeamMemberDestroyAction.ts +36 -17
- package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +41 -28
- package/app/Actions/Dashboard/Teams/TeamPeopleIndexAction.ts +71 -58
- package/app/Actions/Dashboard/Teams/team-invitation-delivery.ts +29 -7
- package/app/Actions/Dashboard/Teams/team-member-count.ts +4 -2
- package/app/Actions/Dashboard/Teams/team-records.test.ts +2 -0
- package/app/Actions/Dashboard/Teams/team-records.ts +2 -0
- package/app/Actions/Dashboard/dashboard-request.ts +13 -0
- package/app/Actions/Dashboard/dashboard-response.ts +16 -0
- package/app/Actions/SendWelcomeEmail.ts +1 -1
- package/app/Actions/Teams/AcceptInvitationAction.ts +74 -51
- package/app/Actions/Teams/ShowInvitationAction.ts +21 -14
- package/app/Actions/Teams/team-response.ts +23 -0
- package/app/Mail/OrderConfirmation.ts +1 -1
- package/app/Mail/SubscriptionConfirmation.ts +1 -1
- package/app/Mail/TeamInvitation.ts +1 -1
- package/app/Models/CardComment.ts +3 -4
- package/app/Models/EmailIdempotency.ts +64 -0
- package/app/Models/EmailSuppression.ts +52 -0
- package/app/Models/EmailWebhookEvent.ts +51 -0
- package/app/Models/TeamInvitation.ts +13 -0
- package/functions/dashboard-api.ts +51 -10
- package/functions/data.ts +0 -1
- package/functions/inbox.test.ts +2 -0
- package/functions/inbox.ts +27 -2
- package/functions/jobs.ts +0 -1
- package/functions/public-application-url.test.ts +13 -0
- package/functions/public-application-url.ts +27 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +1 -1
- package/resources/components/Dashboard/Auth/AccessTokens.stx +15 -0
- package/resources/components/Dashboard/Ci/CiDashboard.stx +24 -6
- package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +1 -1
- package/resources/components/Dashboard/Commerce/ProductWaitlistDashboard.stx +8 -7
- package/resources/components/Dashboard/Commerce/RestaurantWaitlistDashboard.stx +8 -7
- package/resources/components/Dashboard/Content/BlogDashboard.stx +14 -2
- package/resources/components/Dashboard/Content/PostsDashboard.stx +7 -1
- package/resources/components/Dashboard/Data/DataRecords.stx +0 -4
- package/resources/components/Dashboard/Deployments/DeploymentDetail.stx +16 -2
- package/resources/components/Dashboard/Email/EmailNavigation.stx +5 -5
- package/resources/components/Dashboard/Email/EmailSidebar.stx +1 -1
- package/resources/components/Dashboard/Email/InboxAttachmentList.stx +53 -0
- package/resources/components/Dashboard/Email/InboxDashboard.stx +5 -4
- package/resources/components/Dashboard/Email/InboxMessageDetail.stx +6 -4
- package/resources/components/Dashboard/Jobs/JobDetail.stx +3 -2
- package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +63 -6
- package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +3 -6
- package/resources/components/Dashboard/Management/PermissionsDashboard.stx +4 -4
- package/resources/components/Dashboard/Models/ModelRecordsDashboard.stx +7 -13
- package/resources/components/Dashboard/Models/ModelsOverview.stx +1 -1
- package/resources/components/Dashboard/NotFoundDashboard.stx +2 -5
- package/resources/components/Dashboard/Queries/QueryDashboard.stx +6 -2
- package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +4 -4
- package/resources/components/Dashboard/UI/EmptyState.stx +2 -2
- package/resources/components/Dashboard/UI/PageLayout.stx +3 -3
- package/resources/functions/dashboard/sidebar.ts +4 -4
- package/routes/dashboard-api.ts +8 -20
- package/views/dashboard/.discovered-models.json +54 -0
- package/views/dashboard/layouts/default.stx +19 -16
- package/views/dashboard/stores/auth.ts +15 -14
- package/views/dashboard/stores/ci.ts +13 -7
- package/views/dashboard/stores/kanban.ts +48 -11
|
@@ -96,12 +96,27 @@ bun storage/framework/defaults/ai/skills/stacks-browse/scripts/browse.ts snapsho
|
|
|
96
96
|
```
|
|
97
97
|
Extracts headings, links (`text -> href`), buttons, forms (action + field count), and ARIA landmarks — useful for auditing structure and catching broken links.
|
|
98
98
|
|
|
99
|
+
### Scenario (stateful SPA interactions)
|
|
100
|
+
```bash
|
|
101
|
+
bun storage/framework/defaults/ai/skills/stacks-browse/scripts/browse.ts scenario <url> \
|
|
102
|
+
--step '{"action":"click","selector":"button[data-open]"}' \
|
|
103
|
+
--step '{"action":"fill","selector":"input[name=name]","value":"Example"}' \
|
|
104
|
+
--step '{"action":"click","selector":"button[type=submit]"}' \
|
|
105
|
+
--step '{"action":"assert","selector":"main","text":"Saved"}' \
|
|
106
|
+
--out storage/framework/runtime/shots/scenario.png
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Steps run in order within one isolated browser page, preserving reactive STX state and SPA navigation. Supported actions are `click`, `fill`, `press`, `wait`, and `assert`. Each step is a JSON object passed through a repeatable `--step` flag. A click step may include `text` to select the matching control from its CSS selector. The command reports every completed step, the final URL and page text, console exceptions, failed requests, and an optional screenshot. It exits nonzero when the scenario assertion, browser console, or network fails.
|
|
110
|
+
|
|
99
111
|
### Crawl (whole-site browser audit)
|
|
100
112
|
```bash
|
|
101
|
-
bun storage/framework/defaults/ai/skills/stacks-browse/scripts/browse.ts crawl <url> [--max 500] [--path /extra-route] [--settle 350] [--progress]
|
|
113
|
+
bun storage/framework/defaults/ai/skills/stacks-browse/scripts/browse.ts crawl <url> [--viewport 1280x900] [--max 500] [--path /extra-route] [--settle 350] [--progress]
|
|
102
114
|
```
|
|
103
|
-
|
|
115
|
+
Uses a fresh isolated page target per route, relaunching Chromium if its DevTools
|
|
116
|
+
session exits during a long audit, while
|
|
104
117
|
following every same-origin link it discovers.
|
|
118
|
+
The viewport is deterministic and defaults to `1280x900`; pass `--viewport 768x1024`
|
|
119
|
+
to repeat the same route audit at a tablet breakpoint.
|
|
105
120
|
Each page is checked for a non-200 document, console exceptions, failed
|
|
106
121
|
requests, and horizontal overflow. Repeat `--path` to seed routes that are not
|
|
107
122
|
linked from the starting page. The command prints every crawled path and exits
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* bun browse.ts responsive <url> [--out-dir DIR]
|
|
13
13
|
* bun browse.ts monitor <url> [--ms 5000]
|
|
14
14
|
* bun browse.ts snapshot <url>
|
|
15
|
-
* bun browse.ts
|
|
15
|
+
* bun browse.ts scenario <url> --step '{"action":"click","selector":"button"}'
|
|
16
|
+
* bun browse.ts crawl <url> [--viewport 1280x900] [--max 500] [--path /extra] [--progress]
|
|
16
17
|
*
|
|
17
18
|
* Browser discovery order: $BROWSE_BROWSER → PATH (chromium, google-chrome, …)
|
|
18
19
|
* → common macOS app bundles → a Playwright-cached chromium as last resort.
|
|
@@ -405,6 +406,14 @@ interface CrawlPage {
|
|
|
405
406
|
consoleErrors: string[]
|
|
406
407
|
failedRequests: Array<{ status: number, url: string }>
|
|
407
408
|
horizontalOverflowPx: number
|
|
409
|
+
layout: {
|
|
410
|
+
bodyScrollWidth: number
|
|
411
|
+
documentScrollWidth: number
|
|
412
|
+
mainClientWidth: number | null
|
|
413
|
+
mainOverflowX: string | null
|
|
414
|
+
mainScrollWidth: number | null
|
|
415
|
+
viewportWidth: number
|
|
416
|
+
}
|
|
408
417
|
overflowingElements: Array<{
|
|
409
418
|
element: string
|
|
410
419
|
left: number
|
|
@@ -488,6 +497,118 @@ function parseViewport(value: string | undefined): { w: number, h: number } {
|
|
|
488
497
|
return { w, h }
|
|
489
498
|
}
|
|
490
499
|
|
|
500
|
+
type ScenarioAction = 'assert' | 'click' | 'fill' | 'press' | 'wait'
|
|
501
|
+
|
|
502
|
+
interface ScenarioStep {
|
|
503
|
+
action: ScenarioAction
|
|
504
|
+
selector?: string
|
|
505
|
+
text?: string
|
|
506
|
+
value?: string
|
|
507
|
+
key?: string
|
|
508
|
+
ms?: number
|
|
509
|
+
settle?: number
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function parseScenarioStep(value: string): ScenarioStep {
|
|
513
|
+
let parsed: unknown
|
|
514
|
+
try {
|
|
515
|
+
parsed = JSON.parse(value)
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
throw new TypeError(`Invalid scenario step JSON: ${value}`, { cause: error })
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
522
|
+
throw new TypeError('Each scenario step must be a JSON object.')
|
|
523
|
+
|
|
524
|
+
const step = parsed as Record<string, unknown>
|
|
525
|
+
if (!['assert', 'click', 'fill', 'press', 'wait'].includes(String(step.action)))
|
|
526
|
+
throw new TypeError(`Unsupported scenario action: ${String(step.action)}`)
|
|
527
|
+
|
|
528
|
+
const action = step.action as ScenarioAction
|
|
529
|
+
if (['assert', 'click', 'fill'].includes(action) && typeof step.selector !== 'string')
|
|
530
|
+
throw new TypeError(`Scenario action "${action}" requires a selector.`)
|
|
531
|
+
if (action === 'fill' && typeof step.value !== 'string')
|
|
532
|
+
throw new TypeError('Scenario action "fill" requires a string value.')
|
|
533
|
+
if (action === 'press' && typeof step.key !== 'string')
|
|
534
|
+
throw new TypeError('Scenario action "press" requires a key.')
|
|
535
|
+
if (action === 'wait' && (!Number.isFinite(step.ms) || Number(step.ms) < 0 || Number(step.ms) > 30_000))
|
|
536
|
+
throw new TypeError('Scenario action "wait" requires ms between 0 and 30000.')
|
|
537
|
+
|
|
538
|
+
return step as unknown as ScenarioStep
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function runScenarioStep(cdp: Cdp, step: ScenarioStep): Promise<Record<string, unknown>> {
|
|
542
|
+
if (step.action === 'wait') {
|
|
543
|
+
await Bun.sleep(Number(step.ms))
|
|
544
|
+
return { action: step.action, ms: Number(step.ms), ok: true }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (step.action === 'press') {
|
|
548
|
+
const key = step.key || ''
|
|
549
|
+
const keyCode = key.length === 1 ? key.charCodeAt(0) : ({ Enter: 13, Escape: 27, Tab: 9 }[key] || 0)
|
|
550
|
+
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code: key, windowsVirtualKeyCode: keyCode })
|
|
551
|
+
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code: key, windowsVirtualKeyCode: keyCode })
|
|
552
|
+
return { action: step.action, key, ok: true }
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const result = await cdp.send('Runtime.evaluate', {
|
|
556
|
+
expression: `(() => {
|
|
557
|
+
const step = ${JSON.stringify(step)}
|
|
558
|
+
const candidates = Array.from(document.querySelectorAll(step.selector))
|
|
559
|
+
const normalizedText = (element) => (element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim()
|
|
560
|
+
const element = step.action === 'click' && step.text !== undefined
|
|
561
|
+
? candidates.find(candidate => normalizedText(candidate) === step.text)
|
|
562
|
+
|| candidates.find(candidate => normalizedText(candidate).includes(step.text))
|
|
563
|
+
: candidates[0]
|
|
564
|
+
if (!element)
|
|
565
|
+
return { ok: false, error: 'Element not found', selector: step.selector }
|
|
566
|
+
|
|
567
|
+
const rect = element.getBoundingClientRect()
|
|
568
|
+
const style = getComputedStyle(element)
|
|
569
|
+
const visible = rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'
|
|
570
|
+
if (!visible)
|
|
571
|
+
return { ok: false, error: 'Element is not visible', selector: step.selector }
|
|
572
|
+
|
|
573
|
+
if (step.action === 'click') {
|
|
574
|
+
if (element.disabled || element.getAttribute('aria-disabled') === 'true')
|
|
575
|
+
return { ok: false, error: 'Element is disabled', selector: step.selector }
|
|
576
|
+
element.click()
|
|
577
|
+
}
|
|
578
|
+
else if (step.action === 'fill') {
|
|
579
|
+
if (!('value' in element))
|
|
580
|
+
return { ok: false, error: 'Element has no value', selector: step.selector }
|
|
581
|
+
const prototype = Object.getPrototypeOf(element)
|
|
582
|
+
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
|
583
|
+
if (setter)
|
|
584
|
+
setter.call(element, step.value)
|
|
585
|
+
else
|
|
586
|
+
element.value = step.value
|
|
587
|
+
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: step.value }))
|
|
588
|
+
element.dispatchEvent(new Event('change', { bubbles: true }))
|
|
589
|
+
}
|
|
590
|
+
else if (step.action === 'assert' && step.text !== undefined) {
|
|
591
|
+
const content = (element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim()
|
|
592
|
+
if (!content.includes(step.text))
|
|
593
|
+
return { ok: false, error: 'Expected text was not found', selector: step.selector, expected: step.text, actual: content.slice(0, 240) }
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
ok: true,
|
|
598
|
+
action: step.action,
|
|
599
|
+
selector: step.selector,
|
|
600
|
+
tag: element.tagName.toLowerCase(),
|
|
601
|
+
text: normalizedText(element).slice(0, 160),
|
|
602
|
+
}
|
|
603
|
+
})()`,
|
|
604
|
+
returnByValue: true,
|
|
605
|
+
})
|
|
606
|
+
const value = result.result?.value as Record<string, unknown> | undefined
|
|
607
|
+
if (!value?.ok)
|
|
608
|
+
throw new Error(`Scenario ${step.action} failed: ${String(value?.error || 'unknown error')} (${step.selector || step.key || ''})`)
|
|
609
|
+
return value
|
|
610
|
+
}
|
|
611
|
+
|
|
491
612
|
const BREAKPOINTS = [
|
|
492
613
|
{ device: 'Mobile S', w: 320, h: 568 },
|
|
493
614
|
{ device: 'Mobile L', w: 428, h: 926 },
|
|
@@ -502,14 +623,14 @@ async function main() {
|
|
|
502
623
|
const url = positional[0]
|
|
503
624
|
|
|
504
625
|
if (!command || command === 'help' || !url) {
|
|
505
|
-
console.log('Usage: bun browse.ts <navigate|screenshot|responsive|monitor|snapshot|crawl> <url> [flags]')
|
|
626
|
+
console.log('Usage: bun browse.ts <navigate|screenshot|responsive|monitor|snapshot|scenario|crawl> <url> [flags]')
|
|
506
627
|
console.log(' --cookie "name=value" repeatable; pre-seeds cookies (e.g. coming-soon bypass)')
|
|
507
628
|
console.log(' --settle 1500 ms to wait after load before acting (default 700; stretch for entrance animations)')
|
|
508
629
|
console.log(' --scheme dark emulate prefers-color-scheme (light|dark) for QA of theme-aware pages')
|
|
509
630
|
process.exit(url ? 0 : 1)
|
|
510
631
|
}
|
|
511
632
|
|
|
512
|
-
|
|
633
|
+
let session = await launch()
|
|
513
634
|
const cookies = flagList(flags.cookie)
|
|
514
635
|
const settleMs = flags.settle ? Number(flags.settle) : undefined
|
|
515
636
|
const scheme = typeof flags.scheme === 'string' ? flags.scheme : undefined
|
|
@@ -554,7 +675,7 @@ async function main() {
|
|
|
554
675
|
const cdp = await openPage(session.port)
|
|
555
676
|
const state = await gotoAndInstrument(cdp, url, { viewport: { w: bp.w, h: bp.h }, cookies, settleMs, scheme })
|
|
556
677
|
const overflow = await cdp.send('Runtime.evaluate', {
|
|
557
|
-
expression: 'document.
|
|
678
|
+
expression: 'document.body.scrollWidth > window.innerWidth ? document.body.scrollWidth - window.innerWidth : 0',
|
|
558
679
|
returnByValue: true,
|
|
559
680
|
})
|
|
560
681
|
const out = join(outDir, `${bp.device.toLowerCase().replace(/\s+/g, '-')}.png`)
|
|
@@ -611,9 +732,57 @@ async function main() {
|
|
|
611
732
|
cdp.close()
|
|
612
733
|
}
|
|
613
734
|
|
|
735
|
+
else if (command === 'scenario') {
|
|
736
|
+
const steps = flagList(flags.step).map(parseScenarioStep)
|
|
737
|
+
if (!steps.length)
|
|
738
|
+
throw new TypeError('Scenario requires at least one --step JSON object.')
|
|
739
|
+
|
|
740
|
+
const cdp = await openPage(session.port)
|
|
741
|
+
const viewport = parseViewport(typeof flags.viewport === 'string' ? flags.viewport : undefined)
|
|
742
|
+
const state = await gotoAndInstrument(cdp, url, { viewport, cookies, settleMs, scheme })
|
|
743
|
+
const results: Record<string, unknown>[] = []
|
|
744
|
+
|
|
745
|
+
for (const step of steps) {
|
|
746
|
+
results.push(await runScenarioStep(cdp, step))
|
|
747
|
+
if (step.action !== 'wait')
|
|
748
|
+
await Bun.sleep(step.settle ?? 250)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const finalState = await cdp.send('Runtime.evaluate', {
|
|
752
|
+
expression: `({
|
|
753
|
+
url: location.href,
|
|
754
|
+
title: document.title,
|
|
755
|
+
activeElement: document.activeElement?.tagName?.toLowerCase() || null,
|
|
756
|
+
bodyText: (document.body.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 500),
|
|
757
|
+
})`,
|
|
758
|
+
returnByValue: true,
|
|
759
|
+
})
|
|
760
|
+
let screenshot: string | null = null
|
|
761
|
+
if (typeof flags.out === 'string') {
|
|
762
|
+
screenshot = flags.out
|
|
763
|
+
mkdirSync(screenshot.split('/').slice(0, -1).join('/') || '.', { recursive: true })
|
|
764
|
+
await Bun.write(screenshot, await captureScreenshot(cdp, { full: !!flags.full }))
|
|
765
|
+
}
|
|
766
|
+
const failedRequests = state.responses.filter(response => response.status >= 400)
|
|
767
|
+
console.log(JSON.stringify({
|
|
768
|
+
url,
|
|
769
|
+
viewport: `${viewport.w}x${viewport.h}`,
|
|
770
|
+
steps: results,
|
|
771
|
+
final: finalState.result?.value,
|
|
772
|
+
screenshot,
|
|
773
|
+
consoleErrors: state.consoleErrors,
|
|
774
|
+
failedRequests,
|
|
775
|
+
}, null, 2))
|
|
776
|
+
if (state.consoleErrors.length || failedRequests.length)
|
|
777
|
+
process.exitCode = 1
|
|
778
|
+
state.dispose()
|
|
779
|
+
cdp.close()
|
|
780
|
+
}
|
|
781
|
+
|
|
614
782
|
else if (command === 'crawl') {
|
|
615
783
|
const start = new URL(url)
|
|
616
784
|
const max = flags.max ? Number(flags.max) : 500
|
|
785
|
+
const crawlViewport = parseViewport(typeof flags.viewport === 'string' ? flags.viewport : undefined)
|
|
617
786
|
if (!Number.isInteger(max) || max < 1)
|
|
618
787
|
throw new TypeError(`Invalid crawl max "${String(flags.max)}". Expected a positive integer.`)
|
|
619
788
|
|
|
@@ -629,9 +798,19 @@ async function main() {
|
|
|
629
798
|
const crawlSettleMs = settleMs ?? 350
|
|
630
799
|
let browserPage = await createPage(session.port)
|
|
631
800
|
let cdp = browserPage.cdp
|
|
801
|
+
const createReplacementPage = async (): Promise<BrowserPage> => {
|
|
802
|
+
try {
|
|
803
|
+
return await createPage(session.port)
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
kill(session)
|
|
807
|
+
session = await launch()
|
|
808
|
+
return createPage(session.port)
|
|
809
|
+
}
|
|
810
|
+
}
|
|
632
811
|
const replacePage = async (): Promise<void> => {
|
|
633
812
|
await closePage(session.port, browserPage)
|
|
634
|
-
browserPage = await
|
|
813
|
+
browserPage = await createReplacementPage()
|
|
635
814
|
cdp = browserPage.cdp
|
|
636
815
|
}
|
|
637
816
|
const inspectPage = async (current: string): Promise<{
|
|
@@ -640,6 +819,7 @@ async function main() {
|
|
|
640
819
|
links?: string[]
|
|
641
820
|
overflow?: number
|
|
642
821
|
overflowing?: CrawlPage['overflowingElements']
|
|
822
|
+
layout?: CrawlPage['layout']
|
|
643
823
|
title?: string
|
|
644
824
|
}
|
|
645
825
|
}> => {
|
|
@@ -651,12 +831,24 @@ async function main() {
|
|
|
651
831
|
cookies,
|
|
652
832
|
settleMs: crawlSettleMs,
|
|
653
833
|
scheme,
|
|
834
|
+
viewport: crawlViewport,
|
|
654
835
|
})
|
|
655
836
|
const inspection = await cdp.send('Runtime.evaluate', {
|
|
656
837
|
expression: `(() => ({
|
|
657
838
|
title: document.title,
|
|
658
839
|
links: Array.from(document.querySelectorAll('a[href]')).map(link => link.href),
|
|
659
|
-
overflow: Math.max(0, document.
|
|
840
|
+
overflow: Math.max(0, document.body.scrollWidth - window.innerWidth),
|
|
841
|
+
layout: (() => {
|
|
842
|
+
const main = document.querySelector('[data-stx-content]')
|
|
843
|
+
return {
|
|
844
|
+
bodyScrollWidth: document.body.scrollWidth,
|
|
845
|
+
documentScrollWidth: document.documentElement.scrollWidth,
|
|
846
|
+
mainClientWidth: main?.clientWidth ?? null,
|
|
847
|
+
mainOverflowX: main ? getComputedStyle(main).overflowX : null,
|
|
848
|
+
mainScrollWidth: main?.scrollWidth ?? null,
|
|
849
|
+
viewportWidth: window.innerWidth,
|
|
850
|
+
}
|
|
851
|
+
})(),
|
|
660
852
|
overflowing: Array.from(document.querySelectorAll('body *'))
|
|
661
853
|
.map((element) => {
|
|
662
854
|
const rect = element.getBoundingClientRect()
|
|
@@ -736,6 +928,14 @@ async function main() {
|
|
|
736
928
|
consoleErrors: [...new Set(state.consoleErrors)],
|
|
737
929
|
failedRequests,
|
|
738
930
|
horizontalOverflowPx: Number(value.overflow) || 0,
|
|
931
|
+
layout: value.layout || {
|
|
932
|
+
bodyScrollWidth: 0,
|
|
933
|
+
documentScrollWidth: 0,
|
|
934
|
+
mainClientWidth: null,
|
|
935
|
+
mainOverflowX: null,
|
|
936
|
+
mainScrollWidth: null,
|
|
937
|
+
viewportWidth: 0,
|
|
938
|
+
},
|
|
739
939
|
overflowingElements: value.overflowing || [],
|
|
740
940
|
})
|
|
741
941
|
if (flags.progress) {
|
|
@@ -767,6 +967,7 @@ async function main() {
|
|
|
767
967
|
console.log(JSON.stringify({
|
|
768
968
|
start: start.href,
|
|
769
969
|
browser: session.browser,
|
|
970
|
+
viewport: `${crawlViewport.w}x${crawlViewport.h}`,
|
|
770
971
|
crawled: pages.length,
|
|
771
972
|
remaining: queue.length,
|
|
772
973
|
max,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: stacks-email
|
|
3
|
-
description: Use when working with email in a Stacks application
|
|
3
|
+
description: Use when working with email in a Stacks application - sending emails via SES/SendGrid/Mailgun/Mailtrap/SMTP, email templates with STX, email drivers, the Mail singleton, the EmailSDK for inbox management, inbound MIME parsing, or email configuration. Covers @stacksjs/email, config/email.ts, and app/Mail/.
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Bun >= 1.3.0, TypeScript
|
|
6
6
|
allowed-tools: Read Edit Write Bash Grep Glob
|
|
@@ -14,6 +14,7 @@ Multi-driver email system with template rendering, S3-based inbox management, an
|
|
|
14
14
|
- Core package: `storage/framework/core/email/src/`
|
|
15
15
|
- Configuration: `config/email.ts`
|
|
16
16
|
- Application mail: `app/Mail/`
|
|
17
|
+
- Persistence models: `storage/framework/defaults/app/Models/EmailSuppression.ts`, `EmailIdempotency.ts`, `EmailWebhookEvent.ts`
|
|
17
18
|
- Email layouts: `storage/framework/defaults/resources/emails/layouts/`
|
|
18
19
|
- Email resources: `storage/framework/defaults/resources/emails/`
|
|
19
20
|
|
|
@@ -23,8 +24,9 @@ email/src/
|
|
|
23
24
|
├── index.ts # All exports
|
|
24
25
|
├── email.ts # Email class + Mail singleton
|
|
25
26
|
├── template.ts # Template rendering engine
|
|
27
|
+
├── inbound-parser.ts # Bounded RFC MIME parsing for inbound mail
|
|
26
28
|
├── types.ts # Types and interfaces
|
|
27
|
-
├── sdk/index.ts # EmailSDK (send + inbox management)
|
|
29
|
+
├── sdk/index.ts # EmailSDK (send + inbox and attachment management)
|
|
28
30
|
└── drivers/
|
|
29
31
|
├── base.ts # BaseEmailDriver abstract class
|
|
30
32
|
├── ses.ts # AWS SES driver
|
|
@@ -53,11 +55,30 @@ await mail.send({
|
|
|
53
55
|
text: 'Hello!'
|
|
54
56
|
})
|
|
55
57
|
|
|
58
|
+
// Use this when later state depends on successful provider delivery.
|
|
59
|
+
await mail.sendOrFail({
|
|
60
|
+
to: 'user@example.com',
|
|
61
|
+
subject: 'Your invitation',
|
|
62
|
+
text: 'Open the invitation link.'
|
|
63
|
+
})
|
|
64
|
+
|
|
56
65
|
// Switch driver
|
|
57
66
|
const sendgridMail = mail.use('sendgrid')
|
|
58
67
|
await sendgridMail.send(message)
|
|
59
68
|
```
|
|
60
69
|
|
|
70
|
+
`mail.send()` always returns an `EmailResult`. Provider rejection is represented
|
|
71
|
+
as `{ success: false, message, provider }`, which is useful for campaign jobs
|
|
72
|
+
that aggregate individual outcomes. It may still throw for invalid framework
|
|
73
|
+
configuration. `mail.sendOrFail()` returns the same successful result and throws
|
|
74
|
+
`EmailDeliveryError` for a structured provider failure. Use `sendOrFail()` when
|
|
75
|
+
an action reports that mail was sent, when a caller relies on `.catch()`, or when
|
|
76
|
+
delivery status is persisted after the call.
|
|
77
|
+
|
|
78
|
+
Keep template rendering fallback separate from provider delivery. Resolve HTML
|
|
79
|
+
and text first, fall back to plain text only when rendering fails, then call
|
|
80
|
+
`sendOrFail()` once outside the template `try` block.
|
|
81
|
+
|
|
61
82
|
## Email Class
|
|
62
83
|
|
|
63
84
|
```typescript
|
|
@@ -106,14 +127,16 @@ import { emailSDK, sendEmail, getInbox, searchEmails, deleteEmail } from '@stack
|
|
|
106
127
|
await sendEmail({ from: { address: 'a@b.com' }, to: 'c@d.com', subject: 'Hi', html: '<p>Hello</p>' })
|
|
107
128
|
|
|
108
129
|
// Send with template
|
|
109
|
-
await emailSDK.sendTemplate({ to: 'user@example.com',
|
|
130
|
+
await emailSDK.sendTemplate({ to: 'user@example.com', template: 'welcome', data: { name: 'John' } })
|
|
110
131
|
|
|
111
132
|
// Read inbox (from S3)
|
|
112
133
|
const emails = await getInbox('chris', { limit: 20 })
|
|
113
134
|
const email = await emailSDK.getEmail('chris', messageId)
|
|
135
|
+
const attachments = await emailSDK.getAttachments('chris', messageId)
|
|
136
|
+
const download = await emailSDK.getAttachment('chris', messageId, attachments?.[0]?.id || '')
|
|
114
137
|
|
|
115
138
|
// Search
|
|
116
|
-
const results = await searchEmails('chris', { from: 'boss', after: '2024-01-01', hasAttachments: true })
|
|
139
|
+
const results = await searchEmails('chris', { from: 'boss', after: new Date('2024-01-01'), hasAttachments: true })
|
|
117
140
|
|
|
118
141
|
// Manage
|
|
119
142
|
await emailSDK.markAsRead('chris', messageId)
|
|
@@ -227,7 +250,7 @@ export async function sendSubscriptionConfirmation({ to, subscriberUuid }: Optio
|
|
|
227
250
|
}
|
|
228
251
|
})
|
|
229
252
|
|
|
230
|
-
await mail.
|
|
253
|
+
await mail.sendOrFail({
|
|
231
254
|
from: { name: config.app.name, address: config.email.from.address },
|
|
232
255
|
to,
|
|
233
256
|
subject: 'Confirm your subscription',
|
|
@@ -237,26 +260,60 @@ export async function sendSubscriptionConfirmation({ to, subscriberUuid }: Optio
|
|
|
237
260
|
}
|
|
238
261
|
```
|
|
239
262
|
|
|
263
|
+
## Delivery persistence models
|
|
264
|
+
|
|
265
|
+
Stacks ships three internal models and their generated migrations:
|
|
266
|
+
|
|
267
|
+
- `EmailSuppression` uses `email_suppressions` and uniquely keys `email + type`.
|
|
268
|
+
- `EmailIdempotency` uses `email_idempotency` and uniquely keys `idempotency_key`.
|
|
269
|
+
- `EmailWebhookEvent` uses `email_webhook_events` and uniquely keys `provider + event_id`.
|
|
270
|
+
|
|
271
|
+
Each model declares authenticated `useApi` index, show, and destroy routes, but
|
|
272
|
+
sets `dashboard.enabled` to false so operational records do not clutter the
|
|
273
|
+
generic model catalog. Sensitive idempotency keys, recipients, subjects, and
|
|
274
|
+
provider event IDs are hidden from generated responses. Run `buddy migrate`
|
|
275
|
+
after upgrading so suppression, send deduplication, and webhook deduplication
|
|
276
|
+
are enforced rather than using their legacy warn-once compatibility path.
|
|
277
|
+
|
|
278
|
+
## Inbound MIME and attachment storage
|
|
279
|
+
|
|
280
|
+
`parseInboundEmail()` parses RFC messages with bounded header, nesting, total-size, and attachment-count limits. It returns normalized sender and recipient data, text and HTML bodies, and binary-safe attachments. Attachment filenames are sanitized before they become S3 keys.
|
|
281
|
+
|
|
282
|
+
`buddy email:reprocess` reads raw messages with `getObjectBytes()`, parses them through this shared helper, and writes:
|
|
283
|
+
|
|
284
|
+
- `raw.eml`
|
|
285
|
+
- `metadata.json`
|
|
286
|
+
- `body.txt` and `body.html` when present
|
|
287
|
+
- binary objects under `attachments/`
|
|
288
|
+
- the per-mailbox `inbox.json` index
|
|
289
|
+
|
|
290
|
+
Reprocessing refreshes existing messages instead of skipping them, preserves their read state, and repairs body and attachment metadata written by older versions. The dashboard receives opaque attachment IDs, resolves them against the stored message before download, and never accepts arbitrary S3 keys from a client.
|
|
291
|
+
|
|
240
292
|
## CLI Commands
|
|
241
|
-
- `buddy email` / `buddy mail`
|
|
242
|
-
- `buddy email:verify`
|
|
243
|
-
- `buddy email:test [recipient]`
|
|
244
|
-
- `buddy email:list`
|
|
245
|
-
- `buddy email:logs -n 50`
|
|
246
|
-
- `buddy email:status`
|
|
247
|
-
- `buddy email:inbox [mailbox]`
|
|
248
|
-
- `buddy
|
|
249
|
-
- `buddy mail:user:
|
|
250
|
-
- `buddy mail:user:
|
|
293
|
+
- `buddy email` / `buddy mail` - email management
|
|
294
|
+
- `buddy email:verify` - check domain verification
|
|
295
|
+
- `buddy email:test [recipient]` - send test email
|
|
296
|
+
- `buddy email:list` - list mailboxes
|
|
297
|
+
- `buddy email:logs -n 50` - view logs
|
|
298
|
+
- `buddy email:status` - server status
|
|
299
|
+
- `buddy email:inbox [mailbox]` - view inbox from S3
|
|
300
|
+
- `buddy email:reprocess` - parse raw S3 mail into mailbox bodies and attachments
|
|
301
|
+
- `buddy mail:user:add <email>` - add mail user
|
|
302
|
+
- `buddy mail:user:list` - list mail users
|
|
303
|
+
- `buddy mail:user:delete <email>` - delete mail user
|
|
251
304
|
|
|
252
305
|
## Gotchas
|
|
253
|
-
- Default driver is `ses`
|
|
306
|
+
- Default driver is `ses` - requires AWS credentials
|
|
254
307
|
- Template rendering supports both `.stx` and `.html` files
|
|
255
308
|
- Variable interpolation uses `{{ }}` double-brace syntax
|
|
256
309
|
- The `mail` singleton auto-registers all 5 drivers on initialization
|
|
257
310
|
- SMTP driver handles TLS handshake manually (not via node:tls)
|
|
258
311
|
- SendGrid/Mailgun retry with exponential backoff on failure
|
|
312
|
+
- `mail.send()` returns structured failures; use `mail.sendOrFail()` when success is required
|
|
313
|
+
- Suppression, send idempotency, and webhook dedup are backed by built-in `useApi` models
|
|
259
314
|
- Mailtrap requires `inboxId` for sandbox mode
|
|
260
315
|
- EmailSDK reads inbox from S3 (bucket configured via env)
|
|
316
|
+
- EmailSDK attachment downloads use binary-safe S3 reads and opaque IDs
|
|
317
|
+
- `buddy email:reprocess` preserves existing read state and exits nonzero on failure
|
|
261
318
|
- Email categorization auto-sorts incoming mail by domain/substring patterns
|
|
262
319
|
- The `text` fallback is auto-generated from HTML via `htmlToText()`
|
|
@@ -53,7 +53,7 @@ export default new Action({
|
|
|
53
53
|
].join('\n')
|
|
54
54
|
|
|
55
55
|
try {
|
|
56
|
-
await mail.
|
|
56
|
+
await mail.sendOrFail({
|
|
57
57
|
to: [toAddress],
|
|
58
58
|
from: { name: appName, address: fromAddress },
|
|
59
59
|
replyTo: email,
|
|
@@ -63,7 +63,7 @@ export default new Action({
|
|
|
63
63
|
}
|
|
64
64
|
catch (err) {
|
|
65
65
|
const errMessage = err instanceof Error ? err.message : String(err)
|
|
66
|
-
console.error('[ContactAction] mail.
|
|
66
|
+
console.error('[ContactAction] mail.sendOrFail failed:', errMessage)
|
|
67
67
|
return { success: false, message: 'We could not send your message. Try again in a moment.' }
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -2,6 +2,7 @@ import type { RequestInstance } from '@stacksjs/types'
|
|
|
2
2
|
import { Action } from '@stacksjs/actions'
|
|
3
3
|
import { AnalyticsEvent } from '@stacksjs/orm'
|
|
4
4
|
import { response } from '@stacksjs/router'
|
|
5
|
+
import { dashboardOperationalError } from '../dashboard-response'
|
|
5
6
|
import {
|
|
6
7
|
analyticsCurrency,
|
|
7
8
|
analyticsIdentifier,
|
|
@@ -50,9 +51,7 @@ export default new Action({
|
|
|
50
51
|
)
|
|
51
52
|
}
|
|
52
53
|
catch (error) {
|
|
53
|
-
return
|
|
54
|
-
message: error instanceof Error ? error.message : 'Event analytics records could not be read.',
|
|
55
|
-
}, 503)
|
|
54
|
+
return dashboardOperationalError(error, 'Event analytics records could not be read.', 'EventAnalyticsAction')
|
|
56
55
|
}
|
|
57
56
|
},
|
|
58
57
|
})
|
|
@@ -2,6 +2,7 @@ import type { RequestInstance } from '@stacksjs/types'
|
|
|
2
2
|
import { Action } from '@stacksjs/actions'
|
|
3
3
|
import { Campaign } from '@stacksjs/orm'
|
|
4
4
|
import { response } from '@stacksjs/router'
|
|
5
|
+
import { dashboardOperationalError } from '../dashboard-response'
|
|
5
6
|
import {
|
|
6
7
|
analyticsCurrency,
|
|
7
8
|
analyticsIdentifier,
|
|
@@ -55,9 +56,7 @@ export default new Action({
|
|
|
55
56
|
)
|
|
56
57
|
}
|
|
57
58
|
catch (error) {
|
|
58
|
-
return
|
|
59
|
-
message: error instanceof Error ? error.message : 'Campaign analytics records could not be read.',
|
|
60
|
-
}, 503)
|
|
59
|
+
return dashboardOperationalError(error, 'Campaign analytics records could not be read.', 'MarketingAnalyticsAction')
|
|
61
60
|
}
|
|
62
61
|
},
|
|
63
62
|
})
|
|
@@ -2,6 +2,7 @@ import type { RequestInstance } from '@stacksjs/types'
|
|
|
2
2
|
import { Action } from '@stacksjs/actions'
|
|
3
3
|
import { Category, Order, OrderItem, Payment, Product } from '@stacksjs/orm'
|
|
4
4
|
import { response } from '@stacksjs/router'
|
|
5
|
+
import { dashboardOperationalError } from '../dashboard-response'
|
|
5
6
|
import {
|
|
6
7
|
analyticsCurrency,
|
|
7
8
|
analyticsIdentifier,
|
|
@@ -93,9 +94,7 @@ export default new Action({
|
|
|
93
94
|
)
|
|
94
95
|
}
|
|
95
96
|
catch (error) {
|
|
96
|
-
return
|
|
97
|
-
message: error instanceof Error ? error.message : 'Sales analytics records could not be read.',
|
|
98
|
-
}, 503)
|
|
97
|
+
return dashboardOperationalError(error, 'Sales analytics records could not be read.', 'SalesAnalyticsAction')
|
|
99
98
|
}
|
|
100
99
|
},
|
|
101
100
|
})
|
|
@@ -2,6 +2,7 @@ import type { RequestInstance } from '@stacksjs/types'
|
|
|
2
2
|
import { Action } from '@stacksjs/actions'
|
|
3
3
|
import { Request } from '@stacksjs/orm'
|
|
4
4
|
import { response } from '@stacksjs/router'
|
|
5
|
+
import { dashboardOperationalError } from '../dashboard-response'
|
|
5
6
|
import {
|
|
6
7
|
buildWebAnalytics,
|
|
7
8
|
normalizeAnalyticsRange,
|
|
@@ -27,9 +28,14 @@ export default new Action({
|
|
|
27
28
|
message: error instanceof Error ? error.message : 'The analytics query is invalid.',
|
|
28
29
|
}, 422)
|
|
29
30
|
}
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
try {
|
|
32
|
+
const records = await Request.orderByDesc('id').limit(10_000).get()
|
|
33
|
+
const rows = records.map(requestAnalyticsRow)
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
return buildWebAnalytics(rows, range, new Date(), scope)
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return dashboardOperationalError(error, 'Web analytics records could not be read.', 'WebAnalyticsAction')
|
|
39
|
+
}
|
|
34
40
|
},
|
|
35
41
|
})
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
1
2
|
import { Action } from '@stacksjs/actions'
|
|
2
3
|
import { getUserRoles } from '@stacksjs/auth'
|
|
4
|
+
import { dashboardOperationalError } from '../dashboard-response'
|
|
3
5
|
|
|
4
6
|
interface MeResponse {
|
|
5
7
|
user: {
|
|
@@ -32,7 +34,7 @@ interface MeResponse {
|
|
|
32
34
|
* a 401 in the network tab. Instead:
|
|
33
35
|
*
|
|
34
36
|
* - If the request carries a valid bearer token, the framework's auth
|
|
35
|
-
* middleware already populated `request.user`
|
|
37
|
+
* middleware already populated `request.user()` - we return that user
|
|
36
38
|
* plus their roles.
|
|
37
39
|
* - If not, we return `{ user: null, roles: [], unauthenticated: true }`.
|
|
38
40
|
* The composable's "no auth → assume dev" default kicks in client-side,
|
|
@@ -48,34 +50,30 @@ export default new Action({
|
|
|
48
50
|
description: 'Returns the authenticated user + their RBAC roles for dashboard role gating.',
|
|
49
51
|
method: 'GET',
|
|
50
52
|
apiResponse: true,
|
|
51
|
-
async handle(request) {
|
|
52
|
-
const user =
|
|
53
|
+
async handle(request: RequestInstance) {
|
|
54
|
+
const user = await request.user()
|
|
53
55
|
|
|
54
|
-
if (!user ||
|
|
56
|
+
if (!user || !Number.isInteger(Number(user.id))) {
|
|
55
57
|
const res: MeResponse = { user: null, roles: [], unauthenticated: true }
|
|
56
58
|
return res
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
const userId = (user
|
|
61
|
+
const userId = Number(user.id)
|
|
60
62
|
|
|
61
|
-
let roles: string[]
|
|
63
|
+
let roles: string[]
|
|
62
64
|
try {
|
|
63
65
|
const records = await getUserRoles(userId)
|
|
64
66
|
roles = records.map(r => r.name)
|
|
65
67
|
}
|
|
66
|
-
catch (
|
|
67
|
-
|
|
68
|
-
// Empty roles → useRole will fall back to its dev default. We don't
|
|
69
|
-
// want to 500 the dashboard just because the role tables haven't
|
|
70
|
-
// been migrated yet on a fresh project.
|
|
71
|
-
console.warn('[dashboard/auth/me] getUserRoles failed:', err instanceof Error ? err.message : err)
|
|
68
|
+
catch (error) {
|
|
69
|
+
return dashboardOperationalError(error, 'Dashboard identity could not be loaded.', 'MeAction')
|
|
72
70
|
}
|
|
73
71
|
|
|
74
72
|
const res: MeResponse = {
|
|
75
73
|
user: {
|
|
76
74
|
id: userId,
|
|
77
|
-
name:
|
|
78
|
-
email:
|
|
75
|
+
name: user.name ?? null,
|
|
76
|
+
email: user.email ?? null,
|
|
79
77
|
},
|
|
80
78
|
roles,
|
|
81
79
|
}
|