@softr/sdk 0.0.97-alpha.1 → 0.0.97
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/dist/action.d.ts +1 -45
- package/dist/action.js +1 -37
- package/dist/default-error-overlay.d.ts +2 -2
- package/dist/formatting.d.ts +3 -3
- package/dist/index.d.ts +2 -3
- package/dist/index.js +2 -2
- package/dist/mcp.d.ts +2 -2
- package/dist/package.js +1 -1
- package/dist/pwa.d.ts +2 -2
- package/dist/server.d.ts +1 -2
- package/dist/server.js +1 -2
- package/package.json +1 -1
- package/skills/softr-sdk/SKILL.md +4 -10
- package/skills/softr-sdk/ai/SKILL.md +3 -1
- package/skills/softr-sdk/auth/SKILL.md +1 -1
- package/skills/softr-sdk/charts-and-metrics/SKILL.md +21 -4
- package/skills/softr-sdk/current-user/SKILL.md +16 -3
- package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
- package/skills/softr-sdk/formatting/SKILL.md +1 -1
- package/skills/softr-sdk/integrations/SKILL.md +1 -3
- package/skills/softr-sdk/mcp/SKILL.md +1 -1
- package/skills/softr-sdk/pwa/SKILL.md +1 -1
- package/skills/softr-sdk/record-mutations/SKILL.md +41 -4
- package/skills/softr-sdk/record-queries/SKILL.md +132 -36
- package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +2 -2
- package/skills/softr-sdk/workflows/SKILL.md +8 -10
- package/dist/core/email.d.ts +0 -46
- package/dist/core/email.js +0 -96
- package/skills/softr-sdk/email/SKILL.md +0 -129
package/dist/action.d.ts
CHANGED
|
@@ -234,50 +234,6 @@ type IntegrationDefinition<TId extends string> = {
|
|
|
234
234
|
* const res = await listEvents({ query: { timeMin: now, singleEvents: "true" } })
|
|
235
235
|
*/
|
|
236
236
|
declare function defineIntegration<const TId extends string>(definition: IntegrationDefinition<TId>): IntegrationAction<TId>;
|
|
237
|
-
type EmailInputs = {
|
|
238
|
-
senderId?: string;
|
|
239
|
-
to?: ReadonlyArray<string>;
|
|
240
|
-
cc?: ReadonlyArray<string>;
|
|
241
|
-
bcc?: ReadonlyArray<string>;
|
|
242
|
-
replyTo?: string;
|
|
243
|
-
};
|
|
244
|
-
type EmailResource = {
|
|
245
|
-
type: "SOFTR_EMAIL";
|
|
246
|
-
inputs: EmailInputs;
|
|
247
|
-
};
|
|
248
|
-
type EmailAction<TId extends string = string> = Action<EmailResource> & {
|
|
249
|
-
id: TId;
|
|
250
|
-
};
|
|
251
|
-
type EmailDefinition<TId extends string> = EmailInputs & {
|
|
252
|
-
id: TId;
|
|
253
|
-
permissions: ActionPermissions;
|
|
254
|
-
};
|
|
255
|
-
/**
|
|
256
|
-
* Declares an email action: who may send it, and the fields every email it sends keeps whatever the
|
|
257
|
-
* call says. Softr sends it from the app's email sender and bills the workspace's workflow credits.
|
|
258
|
-
*
|
|
259
|
-
* - `to`, `cc`, `bcc`: addresses. An element may be a whole `{LOGGED_IN_USER:EMAIL}` or
|
|
260
|
-
* `{LOGGED_IN_USER:<field>}` placeholder, filled in for the signed-in sender.
|
|
261
|
-
* - `replyTo`: an address or one such placeholder.
|
|
262
|
-
* - `senderId`: one of the workspace's email senders, for an email that must come from that address.
|
|
263
|
-
* Left out, the app's sender is used. The call can never choose the sender.
|
|
264
|
-
*
|
|
265
|
-
* A field declared here wins over the call's, and declaring `to` also ignores the call's `cc` and
|
|
266
|
-
* `bcc`. Fix the recipients whenever the use case allows,
|
|
267
|
-
* because anyone in the audience can otherwise send any email to anyone.
|
|
268
|
-
*
|
|
269
|
-
* Pass the returned object to `email.send()` at runtime.
|
|
270
|
-
*
|
|
271
|
-
* @example
|
|
272
|
-
* const notifyOwner = defineEmailAction({
|
|
273
|
-
* id: "notifyOwner",
|
|
274
|
-
* to: ["owner@acme.com"],
|
|
275
|
-
* permissions: { userGroupTypes: ["NON_LOGGED_IN_USERS", "LOGGED_IN_USERS"] },
|
|
276
|
-
* })
|
|
277
|
-
*
|
|
278
|
-
* await email.send(notifyOwner, { subject: "New submission", body: html, replyTo: data.email })
|
|
279
|
-
*/
|
|
280
|
-
declare function defineEmailAction<const TId extends string>(definition: EmailDefinition<TId>): EmailAction<TId>;
|
|
281
237
|
type AiResource = {
|
|
282
238
|
type: "SOFTR_AI";
|
|
283
239
|
model: string;
|
|
@@ -312,4 +268,4 @@ type AiDefinition<TId extends string> = {
|
|
|
312
268
|
*/
|
|
313
269
|
declare function defineAiAction<const TId extends string>(definition: AiDefinition<TId>): AiAction<TId>;
|
|
314
270
|
//#endregion
|
|
315
|
-
export { ActionData, ActionPermissions, ActionRecord, ActionShape, AiAction, DatasourceAction,
|
|
271
|
+
export { ActionData, ActionPermissions, ActionRecord, ActionShape, AiAction, DatasourceAction, FieldTypeOf, IntegrationAction, ReadOf, ReadShape, SoftrDatasources, WorkflowAction, defineAiAction, defineIntegration, defineMutation, defineQuery, defineWorkflow };
|
package/dist/action.js
CHANGED
|
@@ -50,42 +50,6 @@ function defineIntegration(definition) {
|
|
|
50
50
|
permissions
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
|
-
/**
|
|
54
|
-
* Declares an email action: who may send it, and the fields every email it sends keeps whatever the
|
|
55
|
-
* call says. Softr sends it from the app's email sender and bills the workspace's workflow credits.
|
|
56
|
-
*
|
|
57
|
-
* - `to`, `cc`, `bcc`: addresses. An element may be a whole `{LOGGED_IN_USER:EMAIL}` or
|
|
58
|
-
* `{LOGGED_IN_USER:<field>}` placeholder, filled in for the signed-in sender.
|
|
59
|
-
* - `replyTo`: an address or one such placeholder.
|
|
60
|
-
* - `senderId`: one of the workspace's email senders, for an email that must come from that address.
|
|
61
|
-
* Left out, the app's sender is used. The call can never choose the sender.
|
|
62
|
-
*
|
|
63
|
-
* A field declared here wins over the call's, and declaring `to` also ignores the call's `cc` and
|
|
64
|
-
* `bcc`. Fix the recipients whenever the use case allows,
|
|
65
|
-
* because anyone in the audience can otherwise send any email to anyone.
|
|
66
|
-
*
|
|
67
|
-
* Pass the returned object to `email.send()` at runtime.
|
|
68
|
-
*
|
|
69
|
-
* @example
|
|
70
|
-
* const notifyOwner = defineEmailAction({
|
|
71
|
-
* id: "notifyOwner",
|
|
72
|
-
* to: ["owner@acme.com"],
|
|
73
|
-
* permissions: { userGroupTypes: ["NON_LOGGED_IN_USERS", "LOGGED_IN_USERS"] },
|
|
74
|
-
* })
|
|
75
|
-
*
|
|
76
|
-
* await email.send(notifyOwner, { subject: "New submission", body: html, replyTo: data.email })
|
|
77
|
-
*/
|
|
78
|
-
function defineEmailAction(definition) {
|
|
79
|
-
const { id, permissions,...inputs } = definition;
|
|
80
|
-
return {
|
|
81
|
-
id,
|
|
82
|
-
resource: {
|
|
83
|
-
type: "SOFTR_EMAIL",
|
|
84
|
-
inputs
|
|
85
|
-
},
|
|
86
|
-
permissions
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
53
|
function isDatasourceAction(value) {
|
|
90
54
|
if (typeof value !== "object" || value === null) return false;
|
|
91
55
|
const candidate = value;
|
|
@@ -105,4 +69,4 @@ function defineAiAction(definition) {
|
|
|
105
69
|
}
|
|
106
70
|
|
|
107
71
|
//#endregion
|
|
108
|
-
export { defineAiAction,
|
|
72
|
+
export { defineAiAction, defineIntegration, defineMutation, defineQuery, defineWorkflow, isDatasourceAction };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as
|
|
1
|
+
import * as react6 from "react";
|
|
2
2
|
|
|
3
3
|
//#region src/default-error-overlay.d.ts
|
|
4
4
|
type Props = {
|
|
@@ -8,6 +8,6 @@ type Props = {
|
|
|
8
8
|
declare function DefaultErrorOverlay({
|
|
9
9
|
error,
|
|
10
10
|
reset
|
|
11
|
-
}: Props):
|
|
11
|
+
}: Props): react6.JSX.Element;
|
|
12
12
|
//#endregion
|
|
13
13
|
export { DefaultErrorOverlay };
|
package/dist/formatting.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import * as
|
|
2
|
+
import * as react0 from "react";
|
|
3
3
|
import { ReactNode } from "react";
|
|
4
4
|
|
|
5
5
|
//#region src/formatting.d.ts
|
|
@@ -94,7 +94,7 @@ declare function FormattingScope({
|
|
|
94
94
|
/** A BCP 47 locale, such as `de-DE`. It drops the app's fixed date format inside the scope. */
|
|
95
95
|
locale?: string;
|
|
96
96
|
children?: ReactNode;
|
|
97
|
-
}):
|
|
97
|
+
}): react0.FunctionComponentElement<react0.ProviderProps<Scope>>;
|
|
98
98
|
/**
|
|
99
99
|
* A date in the app's format, safe to render on the server. A timestamp whose
|
|
100
100
|
* zone is not known yet renders `fallback` until the browser supplies it, so
|
|
@@ -115,6 +115,6 @@ declare function FormattedDate({
|
|
|
115
115
|
time?: boolean;
|
|
116
116
|
/** What renders while the visitor's zone is unknown. */
|
|
117
117
|
fallback?: ReactNode;
|
|
118
|
-
}):
|
|
118
|
+
}): react0.FunctionComponentElement<react0.FragmentProps>;
|
|
119
119
|
//#endregion
|
|
120
120
|
export { DateOptions, DateParts, DateSeparator, DateValue, FixedDateFormat, FormattedDate, Formatting, FormattingScope, WeekDay, formatDate, formatDateTime, formatNumber, useFormatting };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { SoftrUserGroupId, SoftrUserGroups, SoftrUserProperties, User } from "./auth/user.js";
|
|
2
|
-
import { ActionPermissions, SoftrDatasources, defineAiAction,
|
|
2
|
+
import { ActionPermissions, SoftrDatasources, defineAiAction, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
|
|
3
3
|
import { FileAttachment } from "./core/file-upload.js";
|
|
4
4
|
import { FileItem, useFileUpload } from "./core/use-file-upload.js";
|
|
5
5
|
import { AiAnswer, AiFile, AiGenerateInit, AiMessage, AiUsage } from "./core/ai.js";
|
|
6
|
-
import { EmailSendInit, EmailSent } from "./core/email.js";
|
|
7
6
|
import { DateOptions, DateParts, DateSeparator, DateValue, FixedDateFormat, FormattedDate, Formatting, FormattingScope, WeekDay, formatDate, formatDateTime, formatNumber, useFormatting } from "./formatting.js";
|
|
8
7
|
import { SoftrContext } from "./auth/context.js";
|
|
9
8
|
import { AuthSettings } from "./auth/platform.js";
|
|
@@ -19,4 +18,4 @@ import { useUserTimezone } from "./timezone.js";
|
|
|
19
18
|
import { PwaInstall, PwaPlatform, SoftrPwaHead, usePwaInstall } from "./pwa.js";
|
|
20
19
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
21
20
|
import { Audience } from "./route/audience.js";
|
|
22
|
-
export { type ActionPermissions, type AiAnswer, type AiFile, type AiGenerateInit, type AiMessage, type AiUsage, Audience, type AuthOptions, type AuthSettings, AuthSettingsProvider, type DateOptions, type DateParts, type DateSeparator, type DateValue, DefaultErrorOverlay, type
|
|
21
|
+
export { type ActionPermissions, type AiAnswer, type AiFile, type AiGenerateInit, type AiMessage, type AiUsage, Audience, type AuthOptions, type AuthSettings, AuthSettingsProvider, type DateOptions, type DateParts, type DateSeparator, type DateValue, DefaultErrorOverlay, type FieldShape, type FileAttachment, type FileItem, type FixedDateFormat, FormattedDate, type Formatting, FormattingScope, type Page, type PageSettings, type PwaInstall, type PwaPlatform, type SoftrContext, type SoftrDatasources, type SoftrFieldRegistry, SoftrPwaHead, type SoftrUserGroupId, type SoftrUserGroups, type SoftrUserProperties, type User, type WeekDay, assertRouteAccess, checkAccess, defineAiAction, defineIntegration, defineMutation, definePage, defineQuery, defineVisibility, defineWorkflow, fieldShape, formatDate, formatDateTime, formatDuration, formatField, formatNumber, formatValue, getAuthSettings, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, pageHead, registerSoftrFields, softrAuthMiddleware, softrMcpMiddleware, toReturnPath, useAuthSettings, useCurrentUser, useFileUpload, useFormatting, useOverrides, usePwaInstall, useUserTimezone };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineAiAction,
|
|
1
|
+
import { defineAiAction, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
|
|
2
2
|
import { useFileUpload } from "./core/use-file-upload.js";
|
|
3
3
|
import { checkAccess, defineVisibility, isAuthorized } from "./route/visibility.js";
|
|
4
4
|
import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./route/route.js";
|
|
@@ -14,4 +14,4 @@ import { useUserTimezone } from "./timezone.js";
|
|
|
14
14
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
15
15
|
import { Audience } from "./route/audience.js";
|
|
16
16
|
|
|
17
|
-
export { Audience, AuthSettingsProvider, DefaultErrorOverlay, FormattedDate, FormattingScope, SoftrPwaHead, assertRouteAccess, checkAccess, defineAiAction,
|
|
17
|
+
export { Audience, AuthSettingsProvider, DefaultErrorOverlay, FormattedDate, FormattingScope, SoftrPwaHead, assertRouteAccess, checkAccess, defineAiAction, defineIntegration, defineMutation, definePage, defineQuery, defineVisibility, defineWorkflow, fieldShape, formatDate, formatDateTime, formatDuration, formatField, formatNumber, formatValue, getAuthSettings, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, pageHead, registerSoftrFields, softrAuthMiddleware, softrMcpMiddleware, toReturnPath, useAuthSettings, useCurrentUser, useFileUpload, useFormatting, useOverrides, usePwaInstall, useUserTimezone };
|
package/dist/mcp.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DatasourceAction } from "./action.js";
|
|
2
|
-
import * as
|
|
2
|
+
import * as _tanstack_react_start7 from "@tanstack/react-start";
|
|
3
3
|
|
|
4
4
|
//#region src/mcp.d.ts
|
|
5
5
|
|
|
@@ -31,6 +31,6 @@ type SoftrMcpOptions = {
|
|
|
31
31
|
* ],
|
|
32
32
|
* }))
|
|
33
33
|
*/
|
|
34
|
-
declare function softrMcpMiddleware(options?: SoftrMcpOptions):
|
|
34
|
+
declare function softrMcpMiddleware(options?: SoftrMcpOptions): _tanstack_react_start7.RequestMiddlewareAfterServer<{}, undefined, undefined>;
|
|
35
35
|
//#endregion
|
|
36
36
|
export { softrMcpMiddleware };
|
package/dist/package.js
CHANGED
package/dist/pwa.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as
|
|
1
|
+
import * as react4 from "react";
|
|
2
2
|
import "@tanstack/react-start";
|
|
3
3
|
|
|
4
4
|
//#region src/pwa.d.ts
|
|
@@ -23,7 +23,7 @@ import "@tanstack/react-start";
|
|
|
23
23
|
* )
|
|
24
24
|
* }
|
|
25
25
|
*/
|
|
26
|
-
declare function SoftrPwaHead():
|
|
26
|
+
declare function SoftrPwaHead(): react4.FunctionComponentElement<react4.FragmentProps>;
|
|
27
27
|
type PwaPlatform = "ios" | "android" | "desktop";
|
|
28
28
|
type PwaInstall = {
|
|
29
29
|
available: false;
|
package/dist/server.d.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { upload } from "./core/file-upload.js";
|
|
2
2
|
import { AiError, ai } from "./core/ai.js";
|
|
3
|
-
import { EmailError, EmailErrorCode, email } from "./core/email.js";
|
|
4
3
|
import { auth } from "./auth/server.js";
|
|
5
4
|
import { FieldChoice, RecordOption, RecordOptions } from "./core/options.js";
|
|
6
5
|
import { ds } from "./datasource/datasource.js";
|
|
7
6
|
import { workflow } from "./core/workflow.js";
|
|
8
7
|
import { integration } from "./core/integration.js";
|
|
9
8
|
import "@tanstack/react-start/server-only";
|
|
10
|
-
export { AiError,
|
|
9
|
+
export { AiError, type FieldChoice, type RecordOption, type RecordOptions, ai, auth, ds, integration, upload, workflow };
|
package/dist/server.js
CHANGED
|
@@ -4,7 +4,6 @@ import { auth } from "./auth/server.js";
|
|
|
4
4
|
import { workflow } from "./core/workflow.js";
|
|
5
5
|
import { integration } from "./core/integration.js";
|
|
6
6
|
import { AiError, ai } from "./core/ai.js";
|
|
7
|
-
import { EmailError, email } from "./core/email.js";
|
|
8
7
|
import "@tanstack/react-start/server-only";
|
|
9
8
|
|
|
10
|
-
export { AiError,
|
|
9
|
+
export { AiError, ai, auth, ds, integration, upload, workflow };
|
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: core
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.97
|
|
12
|
+
library_version: 0.0.97
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
# Softr SDK core
|
|
@@ -22,8 +22,6 @@ metadata:
|
|
|
22
22
|
>
|
|
23
23
|
> **CRITICAL**: The app is already wired. `src/start.ts`, `src/router.tsx` and `src/routes/__root.tsx` register `softrAuthMiddleware` and resolve the session. Do not add a second registration of it. `softrMcpMiddleware` is the one thing an app adds to `src/start.ts` itself — load the `softr-sdk/mcp` sub-skill before doing so.
|
|
24
24
|
>
|
|
25
|
-
> **CRITICAL**: `email.send` lives on `@softr/sdk/server` and only runs inside a `createServerFn` handler. Use `defineEmailAction` from `@softr/sdk` to declare who may send it and the recipients it fixes. An email the app sends itself needs no workflow and no connected mail service. Load the `softr-sdk/email` sub-skill for the full pattern.
|
|
26
|
-
>
|
|
27
25
|
> **CRITICAL**: `ai.generate` and `ai.stream` live on `@softr/sdk/server` and only run inside a `createServerFn` handler. Use `defineAiAction` from `@softr/sdk` to declare the action with the model it runs on, an id copied from the `ai_list_models` tool. Softr runs the model on its own keys; never ask the user for an API key for the app's own AI feature. Load the `softr-sdk/ai` sub-skill for the full pattern.
|
|
28
26
|
>
|
|
29
27
|
> **CRITICAL**: `workflow.trigger` lives on `@softr/sdk/server` and only runs inside a `createServerFn` handler. Use `defineWorkflow` from `@softr/sdk` to declare the action, then pass it to `workflow.trigger`. Load the `softr-sdk/workflows` sub-skill for the full pattern.
|
|
@@ -34,8 +32,8 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
|
|
|
34
32
|
|
|
35
33
|
| Import | Runs | Holds |
|
|
36
34
|
| --- | --- | --- |
|
|
37
|
-
| `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `
|
|
38
|
-
| `@softr/sdk/server` | Server only | `ds`, `auth`, `
|
|
35
|
+
| `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineAiAction`, `definePage`, `pageHead`, `defineVisibility`, `guardRoute`, `assertRouteAccess`, `isAuthorized`, `checkAccess`, `Audience`, `useCurrentUser`, `useUserTimezone`, `FormattedDate`, `FormattingScope`, `useFormatting`, `formatDate`, `formatDateTime`, `formatNumber`, `formatValue`, `formatField`, `fieldShape`, `registerSoftrFields`, `useOverrides`, `SoftrPwaHead`, `usePwaInstall`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
|
|
36
|
+
| `@softr/sdk/server` | Server only | `ds`, `auth`, `workflow`, `integration`, `ai` |
|
|
39
37
|
| `@softr/sdk/client` | Browser only | `useFileUpload` |
|
|
40
38
|
| `@softr/sdk/vite` | Build | `softr()`, already in `vite.config.ts` |
|
|
41
39
|
|
|
@@ -49,11 +47,10 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
|
|
|
49
47
|
| Fetch records from a datasource | [record-queries/SKILL.md](./record-queries/SKILL.md) |
|
|
50
48
|
| Chart, aggregate or count records | [charts-and-metrics/SKILL.md](./charts-and-metrics/SKILL.md) |
|
|
51
49
|
| Create, update or delete a record | [record-mutations/SKILL.md](./record-mutations/SKILL.md) |
|
|
52
|
-
| Send an email from the app: a form that emails the owner, a confirmation, staff emailing a client | [email/SKILL.md](./email/SKILL.md) |
|
|
53
50
|
| Trigger a Softr workflow | [workflows/SKILL.md](./workflows/SKILL.md) |
|
|
54
51
|
| Sign a visitor in, out, or up | [auth/SKILL.md](./auth/SKILL.md) |
|
|
55
52
|
| Ask an AI model: a summary, an extraction from a document, a classification, a draft, a chat | [ai/SKILL.md](./ai/SKILL.md) |
|
|
56
|
-
| Call a connected third-party API (Slack, Stripe, Gmail
|
|
53
|
+
| Call a connected third-party API (Slack, Stripe, Gmail, …), or an AI model on the user's own provider account | [integrations/SKILL.md](./integrations/SKILL.md) |
|
|
57
54
|
| Open the app to AI assistants over MCP | [mcp/SKILL.md](./mcp/SKILL.md) |
|
|
58
55
|
| Let visitors install the app on their home screen | [pwa/SKILL.md](./pwa/SKILL.md) |
|
|
59
56
|
| Show a date, a time or a number | [formatting/SKILL.md](./formatting/SKILL.md) |
|
|
@@ -79,9 +76,6 @@ Drawing a chart, a stat tile, or any aggregated number?
|
|
|
79
76
|
Submitting a form, editing a record, or deleting one?
|
|
80
77
|
load softr-sdk/record-mutations
|
|
81
78
|
|
|
82
|
-
Sending an email when a form is submitted or a button is pressed?
|
|
83
|
-
load softr-sdk/email — no workflow, no connected mail service
|
|
84
|
-
|
|
85
79
|
Triggering a Softr workflow from a button or form?
|
|
86
80
|
load softr-sdk/workflows
|
|
87
81
|
|
|
@@ -13,7 +13,7 @@ description: >-
|
|
|
13
13
|
metadata:
|
|
14
14
|
type: sub-skill
|
|
15
15
|
library: softr-sdk
|
|
16
|
-
library_version: 0.0.97
|
|
16
|
+
library_version: 0.0.97
|
|
17
17
|
---
|
|
18
18
|
|
|
19
19
|
**Boundary.** `ai` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineAiAction` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -102,6 +102,8 @@ const summarize = createServerFn({ method: 'POST' })
|
|
|
102
102
|
})
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
+
The record id comes from the browser, so it can name any deal. The read of `dealQuery` takes the table's scope, like every other read of the table. Where reps see only their own deals, a read without it briefs anyone on any deal: see [record-queries](../record-queries/SKILL.md#keep-each-visitor-to-their-own-records).
|
|
106
|
+
|
|
105
107
|
## Extraction: an answer that is data
|
|
106
108
|
|
|
107
109
|
A document dropped on the app, fields pulled out of it, a record created — the model reads the file and answers in the shape the record needs. Give it the schema and the file; validate the parsed answer before writing it, since a model may stray from the shape.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.97
|
|
12
|
+
library_version: 0.0.97
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
|
|
@@ -3,13 +3,13 @@ name: charts-and-metrics
|
|
|
3
3
|
description: >-
|
|
4
4
|
Aggregate records in a server function: ds.chart with groupBy, metrics, where,
|
|
5
5
|
orderBy and date buckets for chart rows, ds.metric for one number, and
|
|
6
|
-
distinct values for filter controls
|
|
7
|
-
|
|
8
|
-
records are record-queries.
|
|
6
|
+
distinct values for filter controls, scoped to the records the visitor may
|
|
7
|
+
read. Use for a chart, a stat tile or any counted, summed or averaged number,
|
|
8
|
+
or when a chart comes back wrong. Single records are record-queries.
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.97
|
|
12
|
+
library_version: 0.0.97
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -166,6 +166,23 @@ const loadDashboard = createServerFn().handler(async () => {
|
|
|
166
166
|
|
|
167
167
|
Each tile and panel is its own request, fired in parallel from one handler.
|
|
168
168
|
|
|
169
|
+
### Scope a figure like the records behind it
|
|
170
|
+
|
|
171
|
+
A chart or a metric reads the same records as a list, so it takes the same scope as every read of the table (see [record-queries](../record-queries/SKILL.md#keep-each-visitor-to-their-own-records)). Without it, a "my revenue" tile adds up everyone's sales, and a chart grouped by a person shows the names of people the visitor may not see. Here a rep sees only their own sales, and `usersQuery` is the app's query on the Users table:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { auth, ds } from '@softr/sdk/server'
|
|
175
|
+
|
|
176
|
+
const myRevenue = createServerFn().handler(async ({ context }) => {
|
|
177
|
+
const me = await auth.currentUserRow(context.user, usersQuery, 'email')
|
|
178
|
+
if (me === null) return null
|
|
179
|
+
const { value } = await ds
|
|
180
|
+
.metric(sales, (q) => q.amount.sum)
|
|
181
|
+
.where((q) => q.rep.isOneOf([me.id]))
|
|
182
|
+
return value
|
|
183
|
+
})
|
|
184
|
+
```
|
|
185
|
+
|
|
169
186
|
## Order of calls
|
|
170
187
|
|
|
171
188
|
`groupBy` comes first and exactly once; a chart cannot be awaited before it. `metrics` follows, at most once, or not at all for distinct values. `where` can be chained anywhere, repeatedly, and calling it more than once ands the filters together. `orderBy` sorts by the chart's own keys, group and metric alike, and only sees the keys declared so far, so call it last.
|
|
@@ -4,12 +4,12 @@ description: >-
|
|
|
4
4
|
Read the signed-in Softr visitor: useCurrentUser and the User type,
|
|
5
5
|
context.user in a route's beforeLoad, and context.user inside a server
|
|
6
6
|
function handler, where Start types it from the app's registered request
|
|
7
|
-
middleware. Covers the null guest
|
|
8
|
-
|
|
7
|
+
middleware. Covers the null guest, the generated SoftrUserGroups that narrows
|
|
8
|
+
group ids to the app's own, and reading the visitor's role on the server.
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.97
|
|
12
|
+
library_version: 0.0.97
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
# The current user
|
|
@@ -92,6 +92,19 @@ const getMyInvoices = createServerFn().handler(async ({ context }) => {
|
|
|
92
92
|
|
|
93
93
|
`context.user` is typed. Start derives a handler's context from the app's registered request middleware as well as the server function's own, so there is nothing to import and nothing to cast.
|
|
94
94
|
|
|
95
|
+
### The visitor's role, in a server function
|
|
96
|
+
|
|
97
|
+
A server function that answers by role reads the role from `context.user.userGroupIds`, never from an argument. Use it to pick which query and which scope the role reads, as [record-queries](../record-queries/SKILL.md#keep-each-visitor-to-their-own-records) shows:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
if (!context.user) throw new Error('Not signed in')
|
|
101
|
+
if (context.user.userGroupIds.includes('00000000-0000-0000-0000-00000000000a')) {
|
|
102
|
+
// read with the staff query and the staff scope
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`context.user.id` is the visitor's account id, not their row in the Users table. A link to Users holds the row id, so a filter on such a link takes the row that `auth.currentUserRow` finds.
|
|
107
|
+
|
|
95
108
|
### Group ids are the app's own
|
|
96
109
|
|
|
97
110
|
For every group's name and id, read `docs/user-groups.md`. It lists every group the app has, the predefined ones included. Copy an id from there; never derive one from a group's name.
|
|
@@ -7,7 +7,7 @@ description: >-
|
|
|
7
7
|
metadata:
|
|
8
8
|
type: sub-skill
|
|
9
9
|
library: softr-sdk
|
|
10
|
-
library_version: 0.0.97
|
|
10
|
+
library_version: 0.0.97
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
**Boundary.** `upload` lives on `@softr/sdk/server` and runs inside server functions. `useFileUpload` and `FileItem` live on `@softr/sdk` and run in components. `FileAttachment` comes from `@softr/sdk` and can be imported anywhere.
|
|
@@ -7,7 +7,7 @@ description: >-
|
|
|
7
7
|
metadata:
|
|
8
8
|
type: sub-skill
|
|
9
9
|
library: softr-sdk
|
|
10
|
-
library_version: 0.0.97
|
|
10
|
+
library_version: 0.0.97
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
**Boundary.** `integration` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineIntegration` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -16,8 +16,6 @@ metadata:
|
|
|
16
16
|
|
|
17
17
|
`integration.createFetcher` calls a third-party API through Softr's authenticated proxy. It adds the connected account's credentials automatically — you never set API keys or auth headers yourself. **Every call acts as that one account, whoever the visitor is**, so take what a call reaches, such as a customer or a channel, from the session or a record the server read, never from the arguments the browser sends.
|
|
18
18
|
|
|
19
|
-
An email the app sends is the `email` sub-skill, with nothing to connect. Send through a connected Gmail or Outlook only when the user wants the email to come from their own mailbox.
|
|
20
|
-
|
|
21
19
|
## Setup
|
|
22
20
|
|
|
23
21
|
```ts
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.97
|
|
12
|
+
library_version: 0.0.97
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `softrMcpMiddleware` lives on `@softr/sdk` and is registered once, in `src/start.ts`. The actions you hand it are the same `defineQuery` / `defineMutation` values the app's own pages use — declared wherever they already live, not duplicated for MCP.
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
name: record-mutations
|
|
3
3
|
description: >-
|
|
4
4
|
Create, update and delete records with defineMutation, ds.create, ds.update
|
|
5
|
-
and ds.delete: the payload keyed by your own field names
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
and ds.delete: the payload keyed by your own field names, what each field type
|
|
6
|
+
accepts, and keeping an update or a delete to the records the visitor may
|
|
7
|
+
read. Use when submitting a form, saving an edit or deleting a record, or when
|
|
8
|
+
a write fails or changes nothing. Reads are record-queries.
|
|
8
9
|
metadata:
|
|
9
10
|
type: sub-skill
|
|
10
11
|
library: softr-sdk
|
|
11
|
-
library_version: 0.0.97
|
|
12
|
+
library_version: 0.0.97
|
|
12
13
|
---
|
|
13
14
|
|
|
14
15
|
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineMutation` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -111,6 +112,42 @@ const removeProject = createServerFn({ method: 'POST' })
|
|
|
111
112
|
|
|
112
113
|
Resolves to nothing. A record that cannot be deleted throws.
|
|
113
114
|
|
|
115
|
+
### Change only the records the visitor may read
|
|
116
|
+
|
|
117
|
+
When a role reads only some records of a table, it updates and deletes only those records. `permissions` decides who may run the mutation, not which record. The id comes from the browser, so it can name any record. Before the write, read the record through the same query and scope as every read of the table (see [record-queries](../record-queries/SKILL.md#keep-each-visitor-to-their-own-records)). Refuse the write when that read answers `null`, with the same error as a missing record, so the visitor cannot find out which ids exist. Keep the field that the scope filters on, such as the owner, out of the update's `fields`. If it is in `fields`, a visitor can move a record to another person.
|
|
118
|
+
|
|
119
|
+
Below, `bookings` is the query every read of bookings uses, and `usersQuery` is the app's query on the Users table. `updateBooking` declares `status` only, not `client`.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { auth, ds } from '@softr/sdk/server'
|
|
123
|
+
|
|
124
|
+
const cancelBooking = createServerFn({ method: 'POST' })
|
|
125
|
+
.validator(z.object({ id: z.string() }))
|
|
126
|
+
.handler(async ({ context, data }) => {
|
|
127
|
+
const me = await auth.currentUserRow(context.user, usersQuery, 'email')
|
|
128
|
+
if (me === null) throw new Error('Booking not found')
|
|
129
|
+
const booking = await ds.get(bookings, { id: data.id }).where((q) => q.client.isOneOf([me.id]))
|
|
130
|
+
if (booking === null) throw new Error('Booking not found')
|
|
131
|
+
await ds.update(updateBooking, { id: booking.id, data: { status: { id: 'cancelled' } } })
|
|
132
|
+
})
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
A delete takes the same read before `ds.delete`. A create into a table that takes its parent's scope, such as a message in a chat, reads the parent through its scope before `ds.create` (see [record-queries](../record-queries/SKILL.md#a-table-with-no-link-to-users-takes-its-parents-scope)).
|
|
136
|
+
|
|
137
|
+
### Only the roles that change a table
|
|
138
|
+
|
|
139
|
+
Often fewer roles change a table than read it. A mutation's `customGroups` names only the roles that may change the table, also when more roles read it. In a barber app, every client reads the free hours, but only barbers open or close them. So the slot update names the barbers' group only, and a barber still changes only their own slots, through the read above:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
const updateSlot = defineMutation({
|
|
143
|
+
id: 'updateSlot',
|
|
144
|
+
datasourceId: '00000000-0000-0000-0000-000000000004',
|
|
145
|
+
operation: 'update',
|
|
146
|
+
fields: { open: 'Wd5nKp2xRb8Tc3' },
|
|
147
|
+
permissions: { userGroupTypes: ['LOGGED_IN_USERS'], customGroups: ['00000000-0000-0000-0000-00000000000b'] },
|
|
148
|
+
})
|
|
149
|
+
```
|
|
150
|
+
|
|
114
151
|
### Edit an existing record
|
|
115
152
|
|
|
116
153
|
An edit form needs both halves: a `defineQuery` to load the record and a `defineMutation` to save it. They are separate declarations over the same table, so give both the fields the form touches, and read the loaded values off the query's names.
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
name: record-queries
|
|
3
3
|
description: >-
|
|
4
4
|
Fetch records with defineQuery, ds.list and ds.get: where and orderBy, field
|
|
5
|
-
names of your own choosing, pagination and its limits, and
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
names of your own choosing, pagination and its limits, and keeping each
|
|
6
|
+
visitor to their own records and to the fields their role may see. Use to put
|
|
7
|
+
records on a page or fill a dropdown, to write any server function that reads
|
|
8
|
+
a table, or when a read comes back empty, unsorted or missing a field. Writes
|
|
9
|
+
are record-mutations; totals and chart rows are charts-and-metrics.
|
|
9
10
|
metadata:
|
|
10
11
|
type: sub-skill
|
|
11
12
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.97
|
|
13
|
+
library_version: 0.0.97
|
|
13
14
|
---
|
|
14
15
|
|
|
15
16
|
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. The same holds for any helper that calls `ds`: declare it inside the handler. A function at module level that calls `ds` keeps the server-only import in the browser bundle, and every page stops loading until it moves. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -125,37 +126,6 @@ if (first.nextOffset !== null) {
|
|
|
125
126
|
|
|
126
127
|
`nextOffset` is `null` when there are no more pages. `total` is the count of all matching records, not the count returned.
|
|
127
128
|
|
|
128
|
-
### Scope the read to the visitor
|
|
129
|
-
|
|
130
|
-
```ts
|
|
131
|
-
const myInvoices = defineQuery({
|
|
132
|
-
id: 'myInvoices',
|
|
133
|
-
datasourceId: '00000000-0000-0000-0000-000000000001',
|
|
134
|
-
fields: { owner: 'Gq5wNt8rJy3Hb1', amount: 'Vk9cXm2pSz6Ld4' },
|
|
135
|
-
permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
const listMyInvoices = createServerFn().handler(async ({ context }) => {
|
|
139
|
-
if (!context.user) throw new Error('Not signed in')
|
|
140
|
-
const userId = context.user.id
|
|
141
|
-
return await ds.list(myInvoices).where((q) => q.owner.is(userId))
|
|
142
|
-
})
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
When the owner is a link to the Users table, the row to match is the visitor's own row there, not `context.user.id`. `auth.currentUserRow(context.user, usersQuery, 'email')` finds it by email, or answers `null` when they are signed out or have no row:
|
|
146
|
-
|
|
147
|
-
```ts
|
|
148
|
-
import { auth, ds } from '@softr/sdk/server'
|
|
149
|
-
|
|
150
|
-
const listMyDeals = createServerFn().handler(async ({ context }) => {
|
|
151
|
-
const me = await auth.currentUserRow(context.user, usersQuery, 'email')
|
|
152
|
-
if (me === null) return { records: [], nextOffset: null }
|
|
153
|
-
return await ds.list(deals).where((q) => q.owner.isOneOf([me.id]))
|
|
154
|
-
})
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
The SDK enforces `permissions`, so a visitor outside the declared audience never reaches the records. Which of those records belong to one visitor is a separate question, and the `where` above answers it: a group says nothing about who owns a row, so a per-visitor read filters on `context.user`. See [current-user](../current-user/SKILL.md) for how the visitor gets onto `context`.
|
|
158
|
-
|
|
159
129
|
### Populate a control from a linked table
|
|
160
130
|
|
|
161
131
|
A `LINKED_RECORD` field on a record answers with `{ id, label }` — the label is the linked record's primary field, and the id is what a write or a `where` filter takes. To fill a filter dropdown or form select with **all** options from the linked table (not just the values already referenced by existing records), query the linked table's own datasource:
|
|
@@ -194,6 +164,8 @@ const loadProductStatuses = createServerFn()
|
|
|
194
164
|
.handler(({ data }) => ds.options(productStatuses, { label: 'name', ...data }))
|
|
195
165
|
```
|
|
196
166
|
|
|
167
|
+
`ds.options` takes no `where`, so use it only over a table with no scope. A picker over a scoped table reads with `ds.list` and the scope: see [A picker over a scoped table](#a-picker-over-a-scoped-table).
|
|
168
|
+
|
|
197
169
|
Skip this request when the options are predefined and fixed — hardcode them instead.
|
|
198
170
|
|
|
199
171
|
A `SELECT` field's options are its choices, and the builder can add and rename them in Studio after the app is built. When a dropdown should follow those changes, read them with `ds.choices`. When the app means a fixed choice, such as a "Done" tab, use its id directly. A mistyped id is not caught at compile time, and a write with an id the field lacks adds it as a new choice. `ds.choices` takes any query or mutation that declares the field and answers `[{ id, label, color }]` as the field holds them now. Each `id` is what a `where` and a write take, for a filter dropdown and a form select alike:
|
|
@@ -205,6 +177,130 @@ const loadCategories = createServerFn().handler(() => ds.choices(products, 'cate
|
|
|
205
177
|
|
|
206
178
|
A read the datasource turns down throws rather than coming back empty, and it throws a plain `Error` carrying the status, followed by the reason the datasource gives when it sends one. There is no typed predicate to narrow it with. Handle it where you would handle any other failed fetch: an `errorComponent` on the route, or a check in the handler.
|
|
207
179
|
|
|
180
|
+
## Keep each visitor to their own records
|
|
181
|
+
|
|
182
|
+
`permissions` decides who may run a query. It does not decide which rows they get, and it does not hide a field the query declares. A visitor can call every server function with any arguments, so one read that skips a rule opens the table to them. The examples below use `bookings`, a query over a table where a client sees only their own bookings, and `usersQuery`, the app's query on the Users table.
|
|
183
|
+
|
|
184
|
+
### One scope per table, in every read
|
|
185
|
+
|
|
186
|
+
A privacy rule from the builder's prompt, such as "clients see only their own bookings", belongs to the table, not to the page where it came up. Every server function that reads the table applies the same `where`: the list, the get by id, a search, a picker, a count and a chart. Keep these server functions beside the table's queries, so the copies of the filter stay together and a new read is written next to them. A get by id takes the same `where`, so an id outside the scope reads as `null`.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
// Bookings are private: a client reads only the bookings linked to their own Users row.
|
|
190
|
+
// Every read of bookings applies this filter.
|
|
191
|
+
const getMyBooking = createServerFn()
|
|
192
|
+
.validator(z.object({ id: z.string() }))
|
|
193
|
+
.handler(async ({ context, data }) => {
|
|
194
|
+
const me = await auth.currentUserRow(context.user, usersQuery, 'email')
|
|
195
|
+
if (me === null) return null
|
|
196
|
+
return await ds.get(bookings, { id: data.id }).where((q) => q.client.isOneOf([me.id]))
|
|
197
|
+
})
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The filter matches the field that holds the owner. A link to the Users table holds the visitor's row there, not `context.user.id`. `auth.currentUserRow(context.user, usersQuery, 'email')` finds that row by email, or answers `null` when they are signed out or have no row. A `USER` field holds the account itself, so it filters on `context.user.id` after a check for a signed-out visitor. See [current-user](../current-user/SKILL.md) for how the visitor gets onto `context`.
|
|
201
|
+
|
|
202
|
+
### A picker over a scoped table
|
|
203
|
+
|
|
204
|
+
`ds.options` takes no `where`, so a picker over a scoped table reads with `ds.list` and the scope, and maps the records to the `{ id, label }` the picker takes. Here `title` is the field of `bookings` the picker shows:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
const loadMyBookings = createServerFn()
|
|
208
|
+
.validator(z.object({ term: z.string(), offset: z.number().nullable() }))
|
|
209
|
+
.handler(async ({ context, data }) => {
|
|
210
|
+
const me = await auth.currentUserRow(context.user, usersQuery, 'email')
|
|
211
|
+
if (me === null) return { options: [], nextOffset: null }
|
|
212
|
+
const term = data.term.trim()
|
|
213
|
+
let list = ds.list(bookings).where((q) => q.client.isOneOf([me.id]))
|
|
214
|
+
if (term !== '') list = list.where((q) => q.title.contains(term))
|
|
215
|
+
const page = await list.offset(data.offset).limit(20)
|
|
216
|
+
return {
|
|
217
|
+
options: page.records.map((record) => ({ id: record.id, label: record.fields.title ?? '' })),
|
|
218
|
+
nextOffset: page.nextOffset,
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Server functions you add use the same scope
|
|
224
|
+
|
|
225
|
+
A server function you write for a new page or widget applies the table's scope too. When it needs other fields, it may declare its own query over the table, but every query over a scoped table takes the same scope. A query read without it is a second path to the same rows.
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
const nextBookings = defineQuery({
|
|
229
|
+
id: 'nextBookings',
|
|
230
|
+
datasourceId: '00000000-0000-0000-0000-000000000002',
|
|
231
|
+
fields: { date: 'Xp2dLh6vKz9Fa3', client: 'Pc8vNm3kTq6Wz2' },
|
|
232
|
+
permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// Wrong: read with no scope. Every client sees every booking.
|
|
236
|
+
const loadNext = createServerFn().handler(() => ds.list(nextBookings).limit(3))
|
|
237
|
+
|
|
238
|
+
// Right: the same scope as every other read of bookings.
|
|
239
|
+
const loadNext = createServerFn().handler(async ({ context }) => {
|
|
240
|
+
const me = await auth.currentUserRow(context.user, usersQuery, 'email')
|
|
241
|
+
if (me === null) return { records: [], total: 0, nextOffset: null }
|
|
242
|
+
return await ds
|
|
243
|
+
.list(nextBookings)
|
|
244
|
+
.where((q) => q.client.isOneOf([me.id]))
|
|
245
|
+
.orderBy((q) => q.date.asc)
|
|
246
|
+
.limit(3)
|
|
247
|
+
})
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The scope filters on `client`, so `nextBookings` declares it, also when the page does not show it.
|
|
251
|
+
|
|
252
|
+
### A table with no link to Users takes its parent's scope
|
|
253
|
+
|
|
254
|
+
Some tables have no link to Users. Chat messages belong to a chat, and the browser sends the chat id. If a server function reads the messages by that id alone, any chat id reads its messages. The same goes for a game room read by its code. Such a table takes its parent's scope: read the parent through its own scope first. Read or post the children only when that read answers, and filter them on the parent that it answered. Here `chats` has a `members` link to Users, and `messages` has a `chat` link to chats:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
const listMessages = createServerFn()
|
|
258
|
+
.validator(z.object({ chatId: z.string() }))
|
|
259
|
+
.handler(async ({ context, data }) => {
|
|
260
|
+
const me = await auth.currentUserRow(context.user, usersQuery, 'email')
|
|
261
|
+
if (me === null) throw new Error('Chat not found')
|
|
262
|
+
const chat = await ds.get(chats, { id: data.chatId }).where((q) => q.members.isOneOf([me.id]))
|
|
263
|
+
if (chat === null) throw new Error('Chat not found')
|
|
264
|
+
return await ds
|
|
265
|
+
.list(messages)
|
|
266
|
+
.where((q) => q.chat.isOneOf([chat.id]))
|
|
267
|
+
.orderBy((q) => q.sentAt.asc)
|
|
268
|
+
.limit(100)
|
|
269
|
+
})
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
A server function that posts a message reads the chat the same way before `ds.create`.
|
|
273
|
+
|
|
274
|
+
### Other people's records show only what the role needs
|
|
275
|
+
|
|
276
|
+
A read returns every field its query declares, and everything a server function returns reaches the browser, also when the page does not show it. A list of other people, such as a directory or the members of a chat, declares only the fields its role needs, so no email or phone unless the role needs them. A `USER` field reads back with the person's email, so a list that must not show emails does not declare one either. When roles need different fields, declare one query per role, each with its own `fields` and `permissions`, and pick the query on the server by the visitor's groups:
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
const peopleForMembers = defineQuery({
|
|
280
|
+
id: 'peopleForMembers',
|
|
281
|
+
datasourceId: '00000000-0000-0000-0000-000000000003',
|
|
282
|
+
fields: { name: 'Hn4tRw8kPz2Mc5', jobTitle: 'Lb7yQe3vXa9Ns1' },
|
|
283
|
+
permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
const peopleForStaff = defineQuery({
|
|
287
|
+
id: 'peopleForStaff',
|
|
288
|
+
datasourceId: '00000000-0000-0000-0000-000000000003',
|
|
289
|
+
fields: { name: 'Hn4tRw8kPz2Mc5', jobTitle: 'Lb7yQe3vXa9Ns1', email: 'Td2kWm6pFs8Jq4' },
|
|
290
|
+
permissions: { userGroupTypes: ['LOGGED_IN_USERS'], customGroups: ['00000000-0000-0000-0000-00000000000a'] },
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
const listPeople = createServerFn().handler(async ({ context }) => {
|
|
294
|
+
if (!context.user) throw new Error('Not signed in')
|
|
295
|
+
if (context.user.userGroupIds.includes('00000000-0000-0000-0000-00000000000a')) {
|
|
296
|
+
return await ds.list(peopleForStaff).limit(100)
|
|
297
|
+
}
|
|
298
|
+
return await ds.list(peopleForMembers).limit(100)
|
|
299
|
+
})
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
A role that may change only some rows changes only the rows it can read: see [record-mutations](../record-mutations/SKILL.md). A count or a chart over the table takes the same scope: see [charts-and-metrics](../charts-and-metrics/SKILL.md).
|
|
303
|
+
|
|
208
304
|
## Typing
|
|
209
305
|
|
|
210
306
|
Queries you write are type safe and a type error is shown when there's a mistake. The records a read returns are typed the same way, so never validate them again with a zod schema of your own. A schema guesses each field's shape, and one value it misses, such as a person with no email, throws for the whole read and the page stops loading. Show each field with a fallback instead: `record.fields.owner?.name ?? '—'`. For an in depth reference on what fields every datasource has, read `docs/datasources/README.md` first — it lists every registered datasource with a link to its file. Follow those links to get field ids, types, and constraints. Do not guess filenames from table names.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.97
|
|
12
|
+
library_version: 0.0.97
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `definePage`, `pageHead`, the access functions and the error predicates come from `@softr/sdk` and work anywhere.
|
|
@@ -73,7 +73,7 @@ Everyone is both audiences, as in the first line above. A set names each audienc
|
|
|
73
73
|
|
|
74
74
|
Take every id in `customGroups` from `docs/user-groups.md`, which lists each group the app has with its name and id. An id the app does not have is a type error.
|
|
75
75
|
|
|
76
|
-
**`visibility` guards the page, not the server functions it calls.** A call can reach them without the page and with any arguments, so the queries and mutations behind a page carry its groups in their own `permissions`
|
|
76
|
+
**`visibility` guards the page, not the server functions it calls.** A call can reach them without the page and with any arguments, so the queries and mutations behind a page carry its groups in their own `permissions`. `permissions` decide who may run them, not which rows they reach: each server function filters the rows itself, as [record-queries](../record-queries/SKILL.md#keep-each-visitor-to-their-own-records) shows.
|
|
77
77
|
|
|
78
78
|
## Core Patterns
|
|
79
79
|
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.97
|
|
12
|
+
library_version: 0.0.97
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `workflow` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineWorkflow` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -18,8 +18,6 @@ metadata:
|
|
|
18
18
|
|
|
19
19
|
`workflow.trigger` fires a Softr workflow and waits for it to finish. It resolves to `void`.
|
|
20
20
|
|
|
21
|
-
An email the app sends itself, when a form is submitted or a button is pressed, needs no workflow: load the `email` sub-skill. Fire a workflow for what the workflow's own steps do, such as updating a CRM or posting to a connected service.
|
|
22
|
-
|
|
23
21
|
## Setup
|
|
24
22
|
|
|
25
23
|
```ts
|
|
@@ -33,15 +31,15 @@ import { workflow } from '@softr/sdk/server'
|
|
|
33
31
|
Every trigger needs a workflow action: which workflow, which trigger step, who may run it, and any data that is always sent. The workflow must have a **Run custom workflow** trigger — no other trigger type can be fired from app code.
|
|
34
32
|
|
|
35
33
|
```ts
|
|
36
|
-
const
|
|
37
|
-
id: '
|
|
34
|
+
const sendWelcomeEmail = defineWorkflow({
|
|
35
|
+
id: 'sendWelcomeEmail',
|
|
38
36
|
workflowId: 'c9d8e7f6-a5b4-4c3d-8e2f-1a0b9c8d7e6f',
|
|
39
37
|
triggerId: '926da496-749f-4006-999c-197d8a556122',
|
|
40
38
|
permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
41
39
|
body: {
|
|
42
40
|
email: '{LOGGED_IN_USER:EMAIL}', // resolved server-side from the signed-in user
|
|
43
41
|
name: '{LOGGED_IN_USER:NAME}',
|
|
44
|
-
|
|
42
|
+
plan: 'enterprise', // any hardcoded value — always sent, never overridden at runtime
|
|
45
43
|
},
|
|
46
44
|
})
|
|
47
45
|
```
|
|
@@ -72,7 +70,7 @@ These placeholders are resolved server-side when the workflow runs:
|
|
|
72
70
|
const runWorkflow = createServerFn({ method: 'POST' })
|
|
73
71
|
.validator(z.object({ message: z.string() }))
|
|
74
72
|
.handler(async ({ data }) => {
|
|
75
|
-
await workflow.trigger(
|
|
73
|
+
await workflow.trigger(sendWelcomeEmail, { message: data.message })
|
|
76
74
|
})
|
|
77
75
|
```
|
|
78
76
|
|
|
@@ -84,7 +82,7 @@ const runWorkflow = createServerFn({ method: 'POST' })
|
|
|
84
82
|
|
|
85
83
|
```ts
|
|
86
84
|
const run = createServerFn({ method: 'POST' }).handler(async () => {
|
|
87
|
-
await workflow.trigger(
|
|
85
|
+
await workflow.trigger(sendWelcomeEmail)
|
|
88
86
|
})
|
|
89
87
|
```
|
|
90
88
|
|
|
@@ -96,11 +94,11 @@ Omit the second argument when the action's `body` already carries everything the
|
|
|
96
94
|
|
|
97
95
|
```ts
|
|
98
96
|
// Wrong: calling from a click handler
|
|
99
|
-
<button onClick={() => workflow.trigger(
|
|
97
|
+
<button onClick={() => workflow.trigger(sendWelcomeEmail)}>Run</button>
|
|
100
98
|
|
|
101
99
|
// Correct: server function, called from the button
|
|
102
100
|
const run = createServerFn({ method: 'POST' }).handler(() =>
|
|
103
|
-
workflow.trigger(
|
|
101
|
+
workflow.trigger(sendWelcomeEmail),
|
|
104
102
|
)
|
|
105
103
|
// …
|
|
106
104
|
const submit = useServerFn(run)
|
package/dist/core/email.d.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { EmailAction } from "../action.js";
|
|
2
|
-
|
|
3
|
-
//#region src/core/email.d.ts
|
|
4
|
-
declare const EMAIL_ERROR_CODES: readonly ["NOT_FOUND", "INVALID_INPUT", "PLAN_RESTRICTED", "CREDITS_EXHAUSTED", "RATE_LIMITED", "PROVIDER_REJECTED", "UNAVAILABLE", "TIMEOUT"];
|
|
5
|
-
type EmailErrorCode = (typeof EMAIL_ERROR_CODES)[number];
|
|
6
|
-
interface EmailSendInit {
|
|
7
|
-
/** Ignored when the action declares its own. */
|
|
8
|
-
to?: ReadonlyArray<string>;
|
|
9
|
-
/** Ignored when the action declares its own `cc` or `to`. */
|
|
10
|
-
cc?: ReadonlyArray<string>;
|
|
11
|
-
/** Ignored when the action declares its own `bcc` or `to`. */
|
|
12
|
-
bcc?: ReadonlyArray<string>;
|
|
13
|
-
/** Ignored when the action declares its own. */
|
|
14
|
-
replyTo?: string;
|
|
15
|
-
subject: string;
|
|
16
|
-
/** Plain text or HTML. */
|
|
17
|
-
body: string;
|
|
18
|
-
}
|
|
19
|
-
interface EmailSent {
|
|
20
|
-
/** Null when the mail provider did not report one; the email was still sent. */
|
|
21
|
-
messageId: string | null;
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Thrown when Softr did not send the email. `code` says why:
|
|
25
|
-
*
|
|
26
|
-
* - `NOT_FOUND`: no such action, or the caller is not in its audience.
|
|
27
|
-
* - `INVALID_INPUT`: the call's inputs were refused; the message says which.
|
|
28
|
-
* - `PLAN_RESTRICTED`: the workspace's plan does not include sending this email.
|
|
29
|
-
* - `CREDITS_EXHAUSTED`: the workspace has no workflow credits left.
|
|
30
|
-
* - `RATE_LIMITED`: the app sent too many emails lately; try again later.
|
|
31
|
-
* - `PROVIDER_REJECTED`: the mail provider refused the email.
|
|
32
|
-
* - `UNAVAILABLE`: Softr could not send it right now, or could not be reached.
|
|
33
|
-
* - `TIMEOUT`: no answer in time. It may still have been sent.
|
|
34
|
-
*/
|
|
35
|
-
declare class EmailError extends Error {
|
|
36
|
-
readonly status: number;
|
|
37
|
-
readonly code: EmailErrorCode;
|
|
38
|
-
constructor(status: number, code: EmailErrorCode, message: string);
|
|
39
|
-
}
|
|
40
|
-
/** Sends one email through the action. Never retries by itself. */
|
|
41
|
-
declare function sendEmail(action: EmailAction, init: EmailSendInit): Promise<EmailSent>;
|
|
42
|
-
declare const email: {
|
|
43
|
-
send: typeof sendEmail;
|
|
44
|
-
};
|
|
45
|
-
//#endregion
|
|
46
|
-
export { EmailError, EmailErrorCode, EmailSendInit, EmailSent, email };
|
package/dist/core/email.js
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { assert } from "../assertions.js";
|
|
2
|
-
import { softrApiUrl } from "../softr-api.js";
|
|
3
|
-
import { fetch } from "../fetch.js";
|
|
4
|
-
import { sessionConfig } from "../auth/config.js";
|
|
5
|
-
import { readSessionToken } from "../auth/session.js";
|
|
6
|
-
|
|
7
|
-
//#region src/core/email.ts
|
|
8
|
-
/** Longer than Softr waits for the send itself, so its own timeout answers first. */
|
|
9
|
-
const TIMEOUT_MS = 12e4;
|
|
10
|
-
const EMAIL_ERROR_CODES = [
|
|
11
|
-
"NOT_FOUND",
|
|
12
|
-
"INVALID_INPUT",
|
|
13
|
-
"PLAN_RESTRICTED",
|
|
14
|
-
"CREDITS_EXHAUSTED",
|
|
15
|
-
"RATE_LIMITED",
|
|
16
|
-
"PROVIDER_REJECTED",
|
|
17
|
-
"UNAVAILABLE",
|
|
18
|
-
"TIMEOUT"
|
|
19
|
-
];
|
|
20
|
-
/**
|
|
21
|
-
* Thrown when Softr did not send the email. `code` says why:
|
|
22
|
-
*
|
|
23
|
-
* - `NOT_FOUND`: no such action, or the caller is not in its audience.
|
|
24
|
-
* - `INVALID_INPUT`: the call's inputs were refused; the message says which.
|
|
25
|
-
* - `PLAN_RESTRICTED`: the workspace's plan does not include sending this email.
|
|
26
|
-
* - `CREDITS_EXHAUSTED`: the workspace has no workflow credits left.
|
|
27
|
-
* - `RATE_LIMITED`: the app sent too many emails lately; try again later.
|
|
28
|
-
* - `PROVIDER_REJECTED`: the mail provider refused the email.
|
|
29
|
-
* - `UNAVAILABLE`: Softr could not send it right now, or could not be reached.
|
|
30
|
-
* - `TIMEOUT`: no answer in time. It may still have been sent.
|
|
31
|
-
*/
|
|
32
|
-
var EmailError = class extends Error {
|
|
33
|
-
status;
|
|
34
|
-
code;
|
|
35
|
-
constructor(status, code, message) {
|
|
36
|
-
super(message);
|
|
37
|
-
this.name = "EmailError";
|
|
38
|
-
this.status = status;
|
|
39
|
-
this.code = code;
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
/** Sends one email through the action. Never retries by itself. */
|
|
43
|
-
async function sendEmail(action, init) {
|
|
44
|
-
const { to, cc, bcc, replyTo, subject, body } = init;
|
|
45
|
-
const sessionToken = readSessionToken();
|
|
46
|
-
let res;
|
|
47
|
-
try {
|
|
48
|
-
res = await fetch(`${softrApiUrl("SOFTR_PLATFORM_API_URL")}/v1/vibe/applications/${sessionConfig().applicationId}/actions/${action.id}/email`, {
|
|
49
|
-
method: "POST",
|
|
50
|
-
headers: {
|
|
51
|
-
...sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {},
|
|
52
|
-
"Content-Type": "application/json"
|
|
53
|
-
},
|
|
54
|
-
body: JSON.stringify({ input: {
|
|
55
|
-
to,
|
|
56
|
-
cc,
|
|
57
|
-
bcc,
|
|
58
|
-
replyTo,
|
|
59
|
-
subject,
|
|
60
|
-
body
|
|
61
|
-
} }),
|
|
62
|
-
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
63
|
-
});
|
|
64
|
-
} catch (error) {
|
|
65
|
-
if (error instanceof DOMException && error.name === "TimeoutError") throw new EmailError(504, "TIMEOUT", `Email "${action.id}" got no answer in time. It may still have been sent.`);
|
|
66
|
-
throw new EmailError(502, "UNAVAILABLE", `Email "${action.id}" could not reach Softr: ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
-
}
|
|
68
|
-
if (res.ok) {
|
|
69
|
-
const sent = await res.json();
|
|
70
|
-
assert(typeof sent === "object" && sent !== null && "messageId" in sent && (typeof sent.messageId === "string" || sent.messageId === null), "the send answered without a messageId");
|
|
71
|
-
return { messageId: sent.messageId };
|
|
72
|
-
}
|
|
73
|
-
const { code, message } = await readFailure(res, `Email "${action.id}" failed with HTTP ${res.status}`);
|
|
74
|
-
throw new EmailError(res.status, code, message);
|
|
75
|
-
}
|
|
76
|
-
/** The code and message of a send Softr refused, read from its answer; `fallbackMessage` when it sent none. */
|
|
77
|
-
async function readFailure(res, fallbackMessage) {
|
|
78
|
-
const failure = await res.json().catch(() => null);
|
|
79
|
-
const known = typeof failure === "object" && failure !== null ? failure : {};
|
|
80
|
-
return {
|
|
81
|
-
code: ("code" in known ? EMAIL_ERROR_CODES.find((errorCode) => errorCode === known.code) : void 0) ?? codeOfStatus(res.status),
|
|
82
|
-
message: "message" in known && typeof known.message === "string" && known.message !== "" ? known.message : fallbackMessage
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
function codeOfStatus(status) {
|
|
86
|
-
switch (status) {
|
|
87
|
-
case 400: return "INVALID_INPUT";
|
|
88
|
-
case 404: return "NOT_FOUND";
|
|
89
|
-
case 429: return "RATE_LIMITED";
|
|
90
|
-
default: return "UNAVAILABLE";
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
const email = { send: sendEmail };
|
|
94
|
-
|
|
95
|
-
//#endregion
|
|
96
|
-
export { EmailError, email };
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: email
|
|
3
|
-
description: >-
|
|
4
|
-
Send an email from the app itself, with nothing to connect and no workflow:
|
|
5
|
-
defineEmailAction from @softr/sdk declares who may send it and the fields it
|
|
6
|
-
fixes (to, cc, bcc, replyTo, and an optional senderId); email.send from
|
|
7
|
-
@softr/sdk/server sends one with a subject and a text or HTML body, and throws
|
|
8
|
-
an EmailError with a code when Softr does not send it. Load this for a contact
|
|
9
|
-
form that emails the owner, a confirmation to the signed-in user, staff
|
|
10
|
-
emailing a client, or any email the app sends while handling what a person
|
|
11
|
-
does in it.
|
|
12
|
-
metadata:
|
|
13
|
-
type: sub-skill
|
|
14
|
-
library: softr-sdk
|
|
15
|
-
library_version: 0.0.97-alpha.1
|
|
16
|
-
---
|
|
17
|
-
|
|
18
|
-
**Boundary.** `email` and `EmailError` live on `@softr/sdk/server` and run only inside a `createServerFn` handler — importing them into a component fails the build. `defineEmailAction` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
19
|
-
|
|
20
|
-
# Email
|
|
21
|
-
|
|
22
|
-
An app sends an email through an email action. Softr sends it from the app's email sender and bills the workspace's **workflow credits**, so there is no mail service to connect and no workflow to build. Use it for every email the app sends while it handles something a person does in it: a form submitted, a button pressed, a record saved through the app.
|
|
23
|
-
|
|
24
|
-
A workflow is still the answer for an email on a schedule ("every Monday") or on a change made outside the app (a record edited in the table): load `automate-with-workflows` for those. A connected Gmail or Outlook is the answer only when the user wants the email to come from their own mailbox, through the `integrations` sub-skill.
|
|
25
|
-
|
|
26
|
-
`defineEmailAction` missing from `@softr/sdk`? The app was built on an older version. Upgrade `@softr/sdk` to the latest version first, then write the action.
|
|
27
|
-
|
|
28
|
-
## Who can send it, and to whom
|
|
29
|
-
|
|
30
|
-
The action's `permissions` is its audience: everyone in it can send an email through it, directly, without the app's own code. The app's code is not a guard. Only what the declaration fixes holds whatever the caller sends.
|
|
31
|
-
|
|
32
|
-
- **Fix the recipients whenever the use case allows.** A contact form that emails the owner fixes `to` to the owner. "Email me this report" fixes `to` to `{LOGGED_IN_USER:EMAIL}`. Then nobody can send the app's email to anyone else.
|
|
33
|
-
- **When people pick the recipients, narrow the audience.** "Staff email a client" leaves `to` open and keeps the action to the staff group with `customGroups`. An action with open recipients for all signed-in users lets anyone who signs up send any email to anyone from the app's address, up to the workspace's credits. Build that only when the user asks for it, and tell them so in one line. An action open to visitors must fix `to`: one that fixes only `cc` or `bcc` is not stored.
|
|
34
|
-
|
|
35
|
-
## The sender
|
|
36
|
-
|
|
37
|
-
Every email comes from the app's email sender, unless the action names one of the workspace's senders with `senderId`. With no sender set, they come from the app's default `@softr.app` address, the one its sign-in emails use.
|
|
38
|
-
|
|
39
|
-
- Read the app's sender with `application_get` before you say where the emails come from, and name it. Never write "if no sender is set": read it. With no sender set, that tool shows the app's default `@softr.app` address: name that one. Say in your closing message when no sender is set, and that the app should get its own sender before real users rely on its emails.
|
|
40
|
-
- When the app has no sender of its own, list the workspace's senders with `workspace_list_email_senders` and offer the confirmed ones by name: for this email (`senderId`, nothing else changes) or as the app's sender. Never pick one yourself.
|
|
41
|
-
- Changing the app's sender also changes the address its sign-in emails come from. Ask the user before you call `application_configure_email_sender`, and name both effects. A workspace whose plan has no custom senders is refused; then say that sending from their own address needs the Professional plan.
|
|
42
|
-
- Set `senderId` only when the user wants this email from a specific address, such as `billing@`. Pick it from `workspace_list_email_senders`, copied exactly. A sender the mail provider has not confirmed makes every send fail until it is confirmed. That confirmation is all an email action needs; the domain's DNS records (DKIM, Return-Path) are needed only before Studio lets the app pick the address as its own sender, and they help the emails stay out of spam.
|
|
43
|
-
|
|
44
|
-
## What it costs
|
|
45
|
-
|
|
46
|
-
Every email costs **5 workflow credits per recipient**, from the workspace's monthly workflow credits, which its workflows spend too. You do not know the workspace's plan, so never name one or count how many emails it covers. The plan and the closing message give the cost and where to watch it, for example: "Each submission emails you, at 5 workflow credits per email. Track them in Settings → Billing → Workflow usage, where this app has its own row."
|
|
47
|
-
|
|
48
|
-
Emails sent from the preview are real and charged, and the actions in the code reach the live app as soon as they are saved. Send at most one test email, and only when the user asks for one. Never send emails in a loop to try something out.
|
|
49
|
-
|
|
50
|
-
## Declare the action
|
|
51
|
-
|
|
52
|
-
```ts
|
|
53
|
-
const notifyOwner = defineEmailAction({
|
|
54
|
-
id: 'notifyOwner',
|
|
55
|
-
to: ['owner@acme.com'],
|
|
56
|
-
permissions: { userGroupTypes: ['NON_LOGGED_IN_USERS', 'LOGGED_IN_USERS'] },
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
const emailClient = defineEmailAction({
|
|
60
|
-
id: 'emailClient',
|
|
61
|
-
replyTo: '{LOGGED_IN_USER:EMAIL}',
|
|
62
|
-
permissions: { userGroupTypes: ['LOGGED_IN_USERS'], customGroups: ['<the staff group id>'] },
|
|
63
|
-
})
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
- `id` is a name you choose, unique across the app.
|
|
67
|
-
- `to`, `cc`, `bcc`: arrays of addresses. Each one declared here replaces the call's, and declaring `to` also ignores the call's `cc` and `bcc`. An element may be a whole placeholder for the signed-in sender: `{LOGGED_IN_USER:EMAIL}`, or `{LOGGED_IN_USER:<field>}` for an email field of their user record. Never part of a string, never in the call.
|
|
68
|
-
- `replyTo`: an address or one such placeholder.
|
|
69
|
-
- `senderId`: one of the workspace's senders, as above. The call can never set the sender.
|
|
70
|
-
- `permissions`: who may send it, as for queries and mutations.
|
|
71
|
-
|
|
72
|
-
Write the object inline in `defineEmailAction`, every value a literal. A declaration that cannot be read is not stored, and the check reports it.
|
|
73
|
-
|
|
74
|
-
Declare one action per kind of email: the owner's notification and the client's confirmation are two actions, each with its own recipients and audience.
|
|
75
|
-
|
|
76
|
-
## Core pattern
|
|
77
|
-
|
|
78
|
-
```ts
|
|
79
|
-
const submit = createServerFn({ method: 'POST' })
|
|
80
|
-
.validator(contactSchema)
|
|
81
|
-
.handler(async ({ data }) => {
|
|
82
|
-
const saved = await ds.create(saveSubmission, { data })
|
|
83
|
-
try {
|
|
84
|
-
await email.send(notifyOwner, {
|
|
85
|
-
subject: `New message from ${data.name}`,
|
|
86
|
-
body: renderSubmission(data), // the app's own HTML, with what the visitor typed escaped
|
|
87
|
-
replyTo: data.email,
|
|
88
|
-
})
|
|
89
|
-
} catch (error) {
|
|
90
|
-
if (error instanceof EmailError) return { saved: true, emailed: false, reason: error.message }
|
|
91
|
-
throw error
|
|
92
|
-
}
|
|
93
|
-
return { saved: true, emailed: true }
|
|
94
|
-
})
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
`email.send(action, init)` takes:
|
|
98
|
-
|
|
99
|
-
- `subject` and `body`. Required. `body` is plain text or HTML. Escape what visitors typed before you put it into HTML.
|
|
100
|
-
- `to`, `cc`, `bcc`, `replyTo`: each used only where the action does not fix it, and `cc` and `bcc` never when it fixes `to`. Each entry is one address. At most 50 recipients per email.
|
|
101
|
-
|
|
102
|
-
It answers `{ messageId }` once the email is sent. The answer never lists the recipients. An email carries no attachments: link to the file instead.
|
|
103
|
-
|
|
104
|
-
Every call is a new email. Disable the button while the request runs, so a double click does not send twice.
|
|
105
|
-
|
|
106
|
-
## Failure modes
|
|
107
|
-
|
|
108
|
-
**Not handling `EmailError`.** `email.send` throws an `EmailError` from `@softr/sdk/server`, with `status`, `code` and `message` when the email was not sent, and never retries by itself. Catch it, keep what the request already saved, and show the person a line they can act on: "Saved, but the email could not be sent." The codes:
|
|
109
|
-
|
|
110
|
-
| `code` | Meaning |
|
|
111
|
-
| --- | --- |
|
|
112
|
-
| `NOT_FOUND` | No such action, or the caller is not in its audience |
|
|
113
|
-
| `INVALID_INPUT` | No subject or body, an entry or placeholder that is not exactly one address, or more than 50 recipients |
|
|
114
|
-
| `PLAN_RESTRICTED` | The workspace's plan does not allow this email |
|
|
115
|
-
| `CREDITS_EXHAUSTED` | The workspace has no workflow credits left |
|
|
116
|
-
| `RATE_LIMITED` | The app sent too often lately. Tell the person to try again later |
|
|
117
|
-
| `PROVIDER_REJECTED` | The mail provider refused the email, for example from an unconfirmed sender |
|
|
118
|
-
| `UNAVAILABLE` | Softr could not send it right now or could not be reached |
|
|
119
|
-
| `TIMEOUT` | No answer in time. It may still have been sent, so never send it again by yourself |
|
|
120
|
-
|
|
121
|
-
**Letting a mistyped address reach Softr.** An address the person typed (the reply-to of a contact form, a client's email) that is not one address is refused with `INVALID_INPUT`, which reads as a vague failure. Check its format in the validator and tell them which field is wrong.
|
|
122
|
-
|
|
123
|
-
**Calling from client code.** `email` is server-only. Wrap the send in a `createServerFn` and call that from the form or the button.
|
|
124
|
-
|
|
125
|
-
**Taking recipients from the request when they could be fixed.** A `to` the browser sends is a `to` any caller can change. Fix it in the declaration, or read it on the server from the record the email is about.
|
|
126
|
-
|
|
127
|
-
**Placeholders in the call.** `{LOGGED_IN_USER:EMAIL}` is filled only in the declaration. In the call it is sent as written and fails.
|
|
128
|
-
|
|
129
|
-
**Building a workflow for an email the app sends itself.** A "Run custom workflow" trigger plus a "Send email" step does the same at more cost to the user: a workflow to keep published. Use the email action; keep workflows for schedules and table events.
|