@stacksjs/types 0.70.45 → 0.70.53

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.
@@ -24,7 +24,7 @@ export type {
24
24
  // type Sorts = any
25
25
  // type Sort = any
26
26
  export declare interface SearchEngineOptions {
27
- driver: 'meilisearch' | 'algolia' | 'opensearch'
27
+ driver: 'meilisearch' | 'algolia' | 'opensearch' | 'typesense'
28
28
  opensearch?: {
29
29
  host: string
30
30
  protocol: number
@@ -43,6 +43,12 @@ export declare interface SearchEngineOptions {
43
43
  apiKey: string
44
44
  searchOnlyApiKey?: string
45
45
  }
46
+ typesense?: {
47
+ host?: string
48
+ port?: number
49
+ protocol?: string
50
+ apiKey?: string
51
+ }
46
52
  filters?: {
47
53
  [key: string]: string
48
54
  }
@@ -118,5 +124,6 @@ export declare interface SearchOptions {
118
124
  sortable: string[]
119
125
  filterable: string[]
120
126
  options?: SearchEngineOptions
127
+ denormalize?: Record<string, string>
121
128
  }
122
129
  export type SearchEngineConfig = Partial<SearchEngineOptions>;
@@ -21,6 +21,14 @@ export declare interface ServicesOptions {
21
21
  redirectUrl: string
22
22
  scopes?: string[]
23
23
  }
24
+ apple?: {
25
+ clientId: string
26
+ teamId: string
27
+ keyId: string
28
+ privateKey: string
29
+ redirectUrl: string
30
+ scopes?: string[]
31
+ }
24
32
  facebook?: {
25
33
  clientId: string
26
34
  clientSecret: string
@@ -134,6 +142,7 @@ export declare interface ServicesOptions {
134
142
  stripe?: {
135
143
  secretKey?: string
136
144
  publicKey?: string
145
+ webhookSecret?: string
137
146
  apiVersion?: string
138
147
  }
139
148
  }
package/dist/stacks.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AiConfig, AnalyticsConfig, AppConfig, AuthConfig, BinaryConfig, CacheConfig, CloudConfig, DashboardConfig, DatabaseConfig, DnsConfig, DocsConfig, EmailConfig, ErrorConfig, FilesystemsConfig, GitConfig, HashingConfig, LibraryConfig, LoggingConfig, NotificationConfig, PaymentConfig, Ports, QueueConfig, RealtimeConfig, SaasConfig, SearchEngineConfig, SecurityConfig, ServicesConfig, Team, UiConfig } from '.';
1
+ import type { AiConfig, AnalyticsConfig, AppConfig, AuthConfig, BinaryConfig, CacheConfig, CloudConfig, CmsConfig, CommerceConfig, CorsConfig, DashboardConfig, DatabaseConfig, DnsConfig, DocsConfig, EmailConfig, ErrorConfig, FilesystemsConfig, GitConfig, HashingConfig, LibraryConfig, LoggingConfig, MarketingConfig, MonitoringConfig, NotificationConfig, PaymentConfig, Ports, QueueConfig, RealtimeConfig, SaasConfig, SearchEngineConfig, SecurityConfig, ServicesConfig, Team, UiConfig } from '.';
2
2
  /**
3
3
  * **Stacks Options**
4
4
  *
@@ -11,10 +11,13 @@ export declare interface StacksOptions {
11
11
  analytics: AnalyticsConfig
12
12
  app: AppConfig
13
13
  auth: AuthConfig
14
+ cors?: CorsConfig
14
15
  realtime: RealtimeConfig
15
16
  cache: CacheConfig
16
17
  cli: BinaryConfig
17
18
  cloud: CloudConfig
19
+ cms: CmsConfig
20
+ commerce: CommerceConfig
18
21
  dashboard: DashboardConfig
19
22
  database: DatabaseConfig
20
23
  dns: DnsConfig
@@ -25,6 +28,8 @@ export declare interface StacksOptions {
25
28
  hashing: HashingConfig
26
29
  library: LibraryConfig
27
30
  logging: LoggingConfig
31
+ marketing: MarketingConfig
32
+ monitoring: MonitoringConfig
28
33
  notification: NotificationConfig
29
34
  payment: PaymentConfig
30
35
  ports: Ports
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/types",
3
3
  "type": "module",
4
- "version": "0.70.45",
4
+ "version": "0.70.53",
5
5
  "description": "The Stacks framework types.",
6
6
  "author": "Chris Breuer",
7
7
  "contributors": [
@@ -45,12 +45,12 @@
45
45
  "prepublishOnly": "bun run build"
46
46
  },
47
47
  "dependencies": {
48
- "@stacksjs/bunpress": "^0.1.4",
49
- "@stacksjs/ts-validation": "^0.4.10",
50
- "bun-query-builder": "^0.1.21"
48
+ "@stacksjs/bunpress": "^0.1.11",
49
+ "@stacksjs/ts-validation": "^0.5.0",
50
+ "bun-query-builder": "^0.1.38"
51
51
  },
52
52
  "devDependencies": {
53
- "@stacksjs/validation": "^0.70.45",
53
+ "@stacksjs/validation": "0.70.53",
54
54
  "@types/bun": "^1.3.11",
55
55
  "typescript": "^5.9.3"
56
56
  },
package/src/auth.ts CHANGED
@@ -1,4 +1,12 @@
1
1
  export interface AuthOptions {
2
+ /**
3
+ * Top-level feature gate. When `false`, the auth feature is inert at boot
4
+ * (no token/password-reset/email-verification flows wired up). Missing or
5
+ * `true` means auth is on.
6
+ */
7
+ enabled?: boolean
8
+ /** Optional deploy-target gate, e.g. `['production']`. */
9
+ env?: string[]
2
10
  /**
3
11
  * The default authentication guard to use
4
12
  */
@@ -90,6 +98,37 @@ export interface AuthOptions {
90
98
  * @default 60
91
99
  */
92
100
  throttle: number
101
+
102
+ /**
103
+ * Reset-link URL template. Supports `{token}` and `{email}`
104
+ * placeholders. Absolute templates (`https://…`) are used as-is;
105
+ * path templates are prefixed with the app URL. Lets apps whose
106
+ * reset page lives on a custom route reuse `passwordResets().sendEmail()`
107
+ * instead of hand-rolling the send.
108
+ * @default '/password/reset/{token}?email={email}'
109
+ */
110
+ url?: string
111
+ }
112
+
113
+ /**
114
+ * Email verification configuration
115
+ */
116
+ emailVerification?: {
117
+ /**
118
+ * Token expiration time in minutes
119
+ * @default 60
120
+ */
121
+ expire?: number
122
+
123
+ /**
124
+ * Verification-link URL template. Supports `{id}` and `{token}`
125
+ * placeholders. Absolute templates (`https://…`) are used as-is;
126
+ * path templates are prefixed with the app URL. Lets apps whose
127
+ * verify page lives on a custom route reuse `sendVerificationEmail()`
128
+ * instead of hand-rolling the send.
129
+ * @default '/verify-email/{id}/{token}'
130
+ */
131
+ url?: string
93
132
  }
94
133
  }
95
134
 
package/src/cache.ts CHANGED
@@ -6,11 +6,15 @@ export interface CacheOptions {
6
6
  * **Cache Driver**
7
7
  *
8
8
  * The cache driver that will be used by your application to store
9
- * cached data. Supports 'memory' and 'redis' drivers.
9
+ * cached data. Supports 'memory', 'redis', and 'singlestore' drivers.
10
+ *
11
+ * The 'singlestore' driver persists cache entries in a SingleStore
12
+ * rowstore table (with epoch-based TTL) — useful when you want a single
13
+ * SingleStore cluster to back both your primary data and your cache.
10
14
  *
11
15
  * @default "memory"
12
16
  */
13
- driver: 'memory' | 'redis'
17
+ driver: 'memory' | 'redis' | 'singlestore'
14
18
 
15
19
  /**
16
20
  * **Cache Prefix**
@@ -117,6 +121,49 @@ export interface CacheOptions {
117
121
  */
118
122
  deleteOnExpire?: boolean
119
123
  }
124
+
125
+ singlestore?: {
126
+ /**
127
+ * SingleStore host (MySQL wire protocol)
128
+ * @default "127.0.0.1"
129
+ */
130
+ host?: string
131
+
132
+ /**
133
+ * SingleStore port
134
+ * @default 3306
135
+ */
136
+ port?: number
137
+
138
+ /**
139
+ * SingleStore username
140
+ * @default "root"
141
+ */
142
+ username?: string
143
+
144
+ /**
145
+ * SingleStore password
146
+ */
147
+ password?: string
148
+
149
+ /**
150
+ * Database that holds the cache table
151
+ * @default "stacks"
152
+ */
153
+ database?: string
154
+
155
+ /**
156
+ * Table used to store cache entries
157
+ * @default "stacks_cache"
158
+ */
159
+ table?: string
160
+
161
+ /**
162
+ * Enable TLS (required by managed SingleStore / Helios)
163
+ * @default false
164
+ */
165
+ ssl?: boolean
166
+ }
120
167
  }
121
168
  }
122
169
 
package/src/cli.ts CHANGED
@@ -238,6 +238,7 @@ export type CreateBooleanOption =
238
238
  | 'functions'
239
239
  | 'api'
240
240
  | 'database'
241
+ | 'minimal'
241
242
  export type CreateOptions = {
242
243
  [key in CreateBooleanOption]: boolean
243
244
  } & {
package/src/cms.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * **CMS Options**
3
+ *
4
+ * Top-level feature gate plus any future CMS-wide settings. The CMS bundle
5
+ * (Post / Page / Author / Comment / Tag / Category models + edit dashboards)
6
+ * stays inert at boot when `enabled` is `false`.
7
+ */
8
+ export interface CmsOptions {
9
+ enabled?: boolean
10
+ /** Optional deploy-target gate, e.g. `['production']`. */
11
+ env?: string[]
12
+ }
13
+
14
+ export type CmsConfig = Partial<CmsOptions>
@@ -0,0 +1,18 @@
1
+ /**
2
+ * **Commerce Options**
3
+ *
4
+ * Top-level feature gate plus storefront defaults. The commerce bundle
5
+ * (Order / Cart / Product / Customer / Coupon / GiftCard / Shipping models +
6
+ * storefront API) stays inert at boot when `enabled` is `false`.
7
+ */
8
+ export interface CommerceOptions {
9
+ enabled?: boolean
10
+ /** Optional deploy-target gate, e.g. `['production']`. */
11
+ env?: string[]
12
+ /** Default storefront currency (ISO 4217), e.g. `'USD'`. */
13
+ currency?: string
14
+ /** Default tax rate applied when no product/region rule overrides. */
15
+ defaultTaxRate?: number
16
+ }
17
+
18
+ export type CommerceConfig = Partial<CommerceOptions>
package/src/cors.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * CORS configuration types (stacksjs/stacks#1859 H-2).
3
+ *
4
+ * Lives in `@stacksjs/types` so userland `config/cors.ts` files can
5
+ * `import type { CorsConfig } from '@stacksjs/types'` and get full
6
+ * IntelliSense, and so the Cors middleware can read
7
+ * `config.cors` with proper typing instead of an `as any` cast.
8
+ */
9
+
10
+ /**
11
+ * Raw CORS configuration — every field optional. Defaults fill in
12
+ * any field that's missing when the middleware resolves config.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * // config/cors.ts
17
+ * import type { CorsConfig } from '@stacksjs/types'
18
+ *
19
+ * export default {
20
+ * origin: ['https://app.example.com', 'https://admin.example.com'],
21
+ * credentials: true,
22
+ * methods: ['GET', 'POST', 'PUT', 'DELETE'],
23
+ * allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
24
+ * maxAge: 600,
25
+ * } satisfies CorsConfig
26
+ * ```
27
+ */
28
+ export interface CorsConfig {
29
+ /**
30
+ * Allowed origins.
31
+ *
32
+ * - `'*'` permits any origin (incompatible with `credentials: true`).
33
+ * - A string array enumerates the explicit allow-list; the request's
34
+ * `Origin` header is reflected verbatim when it matches.
35
+ * - A predicate function lets you implement dynamic allow-lists
36
+ * (e.g. wildcards, regex, environment-dependent).
37
+ */
38
+ origin?: '*' | string[] | ((origin: string) => boolean)
39
+
40
+ /**
41
+ * HTTP methods the server accepts cross-origin. Echoed back in
42
+ * preflight `Access-Control-Allow-Methods` responses.
43
+ */
44
+ methods?: string[]
45
+
46
+ /**
47
+ * Request headers the browser is allowed to send cross-origin.
48
+ * Echoed back in preflight `Access-Control-Allow-Headers` responses.
49
+ */
50
+ allowedHeaders?: string[]
51
+
52
+ /**
53
+ * Response headers the browser is allowed to expose to JS via
54
+ * `fetch(...).then(r => r.headers.get(...))`. Anything not listed
55
+ * here is hidden from the SPA even though it's on the wire.
56
+ */
57
+ exposedHeaders?: string[]
58
+
59
+ /**
60
+ * Whether cookies / Authorization headers may be included
61
+ * cross-origin. **Setting this to `true` is incompatible with
62
+ * `origin: '*'`** — the spec requires an explicit origin.
63
+ */
64
+ credentials?: boolean
65
+
66
+ /**
67
+ * How long the browser may cache the preflight response, in
68
+ * seconds. Default 86_400 (24h). Lower values cost more preflights
69
+ * but pick up policy changes faster.
70
+ */
71
+ maxAge?: number
72
+ }
73
+
74
+ /**
75
+ * Resolved CORS configuration with all defaults applied. This is the
76
+ * shape the middleware passes through `applyCorsHeaders` /
77
+ * `buildPreflightResponse`.
78
+ */
79
+ export interface ResolvedCorsConfig {
80
+ origin: '*' | string[] | ((origin: string) => boolean)
81
+ methods: string[]
82
+ allowedHeaders: string[]
83
+ exposedHeaders: string[]
84
+ credentials: boolean
85
+ maxAge: number
86
+ }
package/src/dashboard.ts CHANGED
@@ -12,6 +12,14 @@
12
12
  * row when there's no newsletter without losing the model viewer.
13
13
  */
14
14
  export interface DashboardOptions {
15
+ /**
16
+ * Top-level feature gate. When `false`, the entire admin SPA + dashboard
17
+ * routes stay un-registered at boot. Missing or `true` keeps the dashboard
18
+ * on — the framework default, since every Stacks app wants it.
19
+ */
20
+ enabled?: boolean
21
+ /** Optional deploy-target gate, e.g. `['production']`. */
22
+ env?: string[]
15
23
  /** Per-section visibility toggles. Omit a section to leave it enabled. */
16
24
  sections?: {
17
25
  library?: { enabled?: boolean }
@@ -35,6 +43,176 @@ export interface DashboardOptions {
35
43
  allModels?: { enabled?: boolean }
36
44
  }
37
45
  }
46
+ /**
47
+ * CI tracking surface — GitHub Actions health across the configured orgs.
48
+ * Ports the standalone `repo-dashboard` app into the dashboard
49
+ * (stacksjs/stacks#1844). Defaults to off because most projects don't
50
+ * own multiple GitHub orgs; opt in by listing orgs and surfacing a
51
+ * `GITHUB_TOKEN` in the environment.
52
+ */
53
+ ci?: {
54
+ enabled?: boolean
55
+ /** Orgs whose repos appear as per-tab CI cards. */
56
+ orgs?: string[]
57
+ /** Self-hosted runner caps per org. Defaults to {@link runnerCapDefault}. */
58
+ runnerCaps?: Record<string, number>
59
+ /** Fallback cap for orgs missing from `runnerCaps`. Defaults to 20. */
60
+ runnerCapDefault?: number
61
+ /** Repo names to exclude from the CI feed. */
62
+ ignoreRepos?: string[]
63
+ /**
64
+ * Failing-CI notification fan-out (stacksjs/stacks#1849).
65
+ *
66
+ * When enabled, the dashboard fires a notification through the
67
+ * configured channels every time a repo's CI transitions from
68
+ * success → failure (or first-time-seen → failure). Sticky-red
69
+ * repos don't keep firing; same-run-id polls don't re-fire; a
70
+ * 5-minute cooldown silences flap-storms.
71
+ */
72
+ notifications?: {
73
+ enabled?: boolean
74
+ /**
75
+ * Channels to fan out through. Maps to the same values
76
+ * `notify()` from @stacksjs/notifications accepts. Defaults to
77
+ * `['chat']` — Slack via webhook — because that channel
78
+ * doesn't need a recipient list (it's already scoped to a
79
+ * Slack channel via env).
80
+ */
81
+ channels?: Array<'email' | 'sms' | 'chat' | 'database'>
82
+ /**
83
+ * Per-channel recipients for the channels that need one
84
+ * (email/sms/database). Skipped for `chat`. Each entry must
85
+ * carry the field the named channel requires (`email`,
86
+ * `phone`, `userId`).
87
+ */
88
+ recipients?: Array<{ email?: string, phone?: string, userId?: number }>
89
+ /**
90
+ * Minimum delay (minutes) between consecutive notifications for
91
+ * the same repo. Defaults to 5. Set to 0 to disable.
92
+ */
93
+ cooldownMinutes?: number
94
+ }
95
+ /**
96
+ * Runner-pressure alerts (stacksjs/stacks#1850).
97
+ *
98
+ * When an org's queued-job count stays at or above
99
+ * `queuedThreshold` for `windowMinutes`, the dashboard fires a
100
+ * notification through the configured channels. Hysteresis: an
101
+ * already-alerting org doesn't re-fire until the queue drops
102
+ * below threshold for a full window first.
103
+ */
104
+ alerts?: {
105
+ enabled?: boolean
106
+ /** Queue depth at or above counts as pressure. Defaults to 8. */
107
+ queuedThreshold?: number
108
+ /** Duration the threshold must hold in either direction before
109
+ * the alert fires / clears. Defaults to 10 minutes. */
110
+ windowMinutes?: number
111
+ /** Same channel options as {@link notifications.channels}. */
112
+ channels?: Array<'email' | 'sms' | 'chat' | 'database'>
113
+ /** Same recipient shape as {@link notifications.recipients}. */
114
+ recipients?: Array<{ email?: string, phone?: string, userId?: number }>
115
+ /**
116
+ * How long the runner-sample time-series is kept on disk.
117
+ * Older samples are pruned during each refresh to bound
118
+ * storage. Defaults to 24h.
119
+ */
120
+ retentionHours?: number
121
+ }
122
+ }
38
123
  }
39
124
 
40
125
  export type DashboardConfig = Partial<DashboardOptions>
126
+
127
+ /**
128
+ * Per-model dashboard configuration (stacksjs/stacks#1843).
129
+ *
130
+ * Attach to a model definition to influence how the model surfaces in the
131
+ * dashboard sidebar without touching the framework's dashboard internals:
132
+ *
133
+ * ```ts
134
+ * defineModel({
135
+ * name: 'AuditLog',
136
+ * table: 'audit_logs',
137
+ * dashboard: {
138
+ * section: 'management',
139
+ * icon: 'shield',
140
+ * roles: ['admin'],
141
+ * description: 'Append-only audit trail (admin-only)',
142
+ * },
143
+ * attributes: { … },
144
+ * })
145
+ * ```
146
+ *
147
+ * Resolution chain (most specific → fallback):
148
+ *
149
+ * 1. `dashboard.enabled === false` → model is hidden from the sidebar
150
+ * entirely. The dynamic ORM viewer (`/models/<id>`) still works for
151
+ * direct navigation, but the row is suppressed.
152
+ * 2. `dashboard.section` → pins the model to that section, overriding
153
+ * the path-based auto-categorisation (commerce/, Content/, etc.).
154
+ * 3. `dashboard.label` / `dashboard.icon` → display overrides; fall back
155
+ * to the model name and `iconMap` lookup.
156
+ * 4. `dashboard.roles` → role-gates the sidebar row. The server-side
157
+ * sidebar builder emits the row with role metadata; the client filters
158
+ * it out for users who lack a matching role. Permissive default
159
+ * (unauthenticated viewers see everything — see `useRole.ts`).
160
+ */
161
+ export interface DashboardModelOptions {
162
+ /**
163
+ * Hide this model from the dashboard sidebar entirely. Direct
164
+ * navigation to `/models/<id>` still works — this only suppresses the
165
+ * sidebar row.
166
+ *
167
+ * Defaults to `true` (model is shown).
168
+ */
169
+ enabled?: boolean
170
+
171
+ /**
172
+ * Override the display name in the sidebar. Defaults to the model name.
173
+ */
174
+ label?: string
175
+
176
+ /**
177
+ * Override the icon. Defaults to the auto-derived one in `iconMap`.
178
+ * The string is whatever the active sidebar icon set expects
179
+ * (e.g., SF Symbol name for the native sidebar; Lucide-style name for
180
+ * the web sidebar).
181
+ */
182
+ icon?: string
183
+
184
+ /**
185
+ * Pin this model to a specific sidebar section instead of the
186
+ * auto-derived category. Useful when a "logs" model lives under
187
+ * `app/Models/` (userland) but should appear under Management rather
188
+ * than Data.
189
+ */
190
+ section?:
191
+ | 'home'
192
+ | 'library'
193
+ | 'content'
194
+ | 'commerce'
195
+ | 'marketing'
196
+ | 'analytics'
197
+ | 'management'
198
+ | 'utilities'
199
+ | 'data'
200
+ | 'app'
201
+
202
+ /**
203
+ * Role-gate the sidebar row. The row is rendered server-side with
204
+ * `data-required-roles="…"`; the client filters it out via
205
+ * `useRole()` if the viewer doesn't hold any of the listed roles.
206
+ *
207
+ * The dev-mode default in `useRole()` means unauthenticated viewers
208
+ * (e.g., the local dev dashboard) see role-gated rows as if they
209
+ * were a dev — see `composables/useRole.ts` for the full chain.
210
+ */
211
+ roles?: string[]
212
+
213
+ /**
214
+ * Short tooltip / hover description for the sidebar row. Optional —
215
+ * sidebars that don't support tooltips ignore it.
216
+ */
217
+ description?: string
218
+ }
package/src/database.ts CHANGED
@@ -13,6 +13,20 @@ export interface DatabaseOptions {
13
13
  prefix?: string
14
14
  }
15
15
 
16
+ // SingleStore is MySQL wire-compatible (port 3306); it shares MySQL's
17
+ // connection shape and adds an optional `ssl` flag for managed (Helios)
18
+ // endpoints, which require TLS.
19
+ singlestore?: {
20
+ url?: string
21
+ host?: string
22
+ port?: number
23
+ name?: string
24
+ username?: string
25
+ password?: string
26
+ prefix?: string
27
+ ssl?: boolean
28
+ }
29
+
16
30
  sqlite: {
17
31
  url?: string
18
32
  database?: string
package/src/email.ts CHANGED
@@ -277,6 +277,23 @@ export interface EmailOptions {
277
277
  }
278
278
 
279
279
  mailboxes: string[] | MailboxConfig[]
280
+
281
+ /**
282
+ * Auto-forwarding rules for received mail, provisioned to the mail server's
283
+ * readable `forwards.json` (re-read on every message — edits take effect
284
+ * with no restart).
285
+ *
286
+ * Key = the delivered mailbox: the full address for a per-domain isolated
287
+ * mailbox (e.g. `'no-reply@acme.com'`), or a bare local-part for a
288
+ * legacy role mailbox (e.g. `'postmaster'`).
289
+ * Value = destination addresses. Targets on a local domain are written
290
+ * straight to that mailbox's Maildir; external targets are relayed.
291
+ * A copy also stays in the source mailbox.
292
+ *
293
+ * @example { 'no-reply@acme.com': ['chris@acme.com'] }
294
+ */
295
+ forwards?: Record<string, string[]>
296
+
280
297
  domain?: string
281
298
 
282
299
  url: string
@@ -286,6 +303,30 @@ export interface EmailOptions {
286
303
  notifications?: EmailNotificationsConfig
287
304
 
288
305
  default: 'log' | 'ses' | 'sendgrid' | 'mailgun' | 'mailtrap' | 'smtp'
306
+
307
+ /**
308
+ * Suppression-list enforcement policy (stacksjs/stacks#1880).
309
+ *
310
+ * - `'strict'` — block all sends to suppressed
311
+ * recipients (default)
312
+ * - `'transactional-allowed'` — block broadcasts; allow messages
313
+ * with `tag: 'transactional'`
314
+ * - `'off'` — never block; the suppression
315
+ * table is just a tracking record
316
+ *
317
+ * The check is opt-in at the table level — apps that haven't
318
+ * created the `email_suppressions` table see "always allowed"
319
+ * with a one-shot warn.
320
+ */
321
+ suppressionPolicy?: 'strict' | 'transactional-allowed' | 'off'
322
+
323
+ /**
324
+ * URL prefix the framework's default unsubscribe route mounts
325
+ * under (stacksjs/stacks#1880). Defaults to
326
+ * `/_stacks/email/unsubscribe`. The full link is built as
327
+ * `${app.url}${unsubscribeRoute}/${signed-token}`.
328
+ */
329
+ unsubscribeRoute?: string
289
330
  }
290
331
 
291
332
  export type EmailConfig = Partial<EmailOptions>
@@ -340,6 +381,17 @@ export interface EmailMessage {
340
381
  cc?: string | string[] | EmailAddress[]
341
382
  /** Blind carbon copy recipient(s) */
342
383
  bcc?: string | string[] | EmailAddress[]
384
+ /**
385
+ * Reply-To address(es) for the outgoing message.
386
+ *
387
+ * Drivers must propagate this to the provider's equivalent field
388
+ * (`ReplyToAddresses` on SES, `h:Reply-To` on Mailgun, `reply_to` on
389
+ * SendGrid, a `Reply-To:` header for SMTP). Previously the field
390
+ * lived as an `as any` stash on the message (stacksjs/stacks#1871 M-4)
391
+ * — promoting it to a first-class slot means drivers can be
392
+ * checked at compile time for coverage.
393
+ */
394
+ replyTo?: EmailAddress | EmailAddress[] | string | string[]
343
395
  /** Email subject line */
344
396
  subject: string
345
397
  /** Path to email template (Vue component) */
@@ -364,6 +416,48 @@ export interface EmailMessage {
364
416
  onError?: (error: Error) => Promise<{ message: string }> | { message: string }
365
417
  /** Optional custom handler */
366
418
  handle?: () => Promise<{ message: string }> | { message: string }
419
+ /**
420
+ * Caller-supplied idempotency key (stacksjs/stacks#1871 M-8).
421
+ *
422
+ * When set, `mail.send()` consults an `email_idempotency` dedup
423
+ * table before dispatching to the driver:
424
+ * - hit: returns the cached EmailResult from the first send
425
+ * - miss: dispatches, then records the result under the key
426
+ *
427
+ * Why it matters: queued send retries (the framework retries 3×
428
+ * with backoff) and external retry loops (webhook handlers that
429
+ * re-fire on transient failures, request POSTs that the user
430
+ * double-clicks) can otherwise deliver the same email multiple
431
+ * times. The key turns those retries into safe no-ops.
432
+ *
433
+ * Construction guidance: derive the key from the business event
434
+ * the email represents — e.g. `welcome:${userId}`,
435
+ * `order-confirmation:${orderId}:${attempt}`, not from message
436
+ * content (which would collide across unrelated sends).
437
+ *
438
+ * The dedup table is opt-in. When the migration hasn't been run
439
+ * yet, the framework warns once and falls back to "send every
440
+ * time" so unrelated apps aren't broken by the new behavior.
441
+ */
442
+ idempotencyKey?: string
443
+ /**
444
+ * Classification used by the suppression-policy check
445
+ * (stacksjs/stacks#1880). Set to `'transactional'` for messages
446
+ * that should bypass suppression when
447
+ * `email.suppressionPolicy: 'transactional-allowed'` is
448
+ * configured — password resets, billing receipts, magic-link
449
+ * sign-ins.
450
+ *
451
+ * Set to `'broadcast'` (or omit) for marketing / newsletter
452
+ * sends; those get blocked when the recipient is suppressed.
453
+ *
454
+ * Under the default `'strict'` policy this field has no effect
455
+ * — both transactional and broadcast sends get blocked. Apps
456
+ * that need to send password-reset emails to bounced addresses
457
+ * (rare, but legitimate) opt into `'transactional-allowed'` AND
458
+ * tag the message.
459
+ */
460
+ tag?: 'transactional' | 'broadcast'
367
461
  }
368
462
 
369
463
  // Email interfaces