@plitzi/sdk-server 0.35.3 → 0.35.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @plitzi/sdk-server
2
2
 
3
+ ## 0.35.5
4
+
5
+ ### Patch Changes
6
+
7
+ - v0.35.5
8
+ - Updated dependencies
9
+ - @plitzi/plitzi-sdk@0.35.5
10
+ - @plitzi/sdk-shared@0.35.5
11
+
12
+ ## 0.35.4
13
+
14
+ ### Patch Changes
15
+
16
+ - v0.35.4
17
+ - Updated dependencies
18
+ - @plitzi/plitzi-sdk@0.35.4
19
+ - @plitzi/sdk-shared@0.35.4
20
+
3
21
  ## 0.35.3
4
22
 
5
23
  ### Patch Changes
package/dist/actions.d.ts CHANGED
@@ -55,7 +55,8 @@ export { createKvStore } from './modules/actions/runtime/kvStore';
55
55
  * and still wants refusals on the log stream wires one of each; that is the normal shape, not a workaround.
56
56
  */
57
57
  export { createRunLogger, createRejectLogger } from './modules/actions/runtime/runLogger';
58
- export type { ActionCredential, ActionDbDriver, ActionKvAdapter, ActionKvStore, ActionLookups, ActionRejectRecord, ActionRunRecord, ActionRunRequest, ActionRunResult, ActionsConfig, ActionTask, ActionTaskContext, ActionTaskRegistry, RegisteredTask, ResolvedConnector } from './modules/actions/types';
58
+ export type { TaskRegistryOptions } from './modules/actions/tasks/registry';
59
+ export type { ActionCredential, ActionDbDriver, ActionEmailConfig, ActionEmailDelivery, ActionEmailMessage, ActionEmailTransport, ActionKvAdapter, ActionKvStore, ActionLookups, ActionRejectRecord, ActionRunRecord, ActionRunRequest, ActionRunResult, ActionsConfig, ActionTask, ActionTaskContext, ActionTaskRegistry, RegisteredTask, ResolvedConnector } from './modules/actions/types';
59
60
  export type { ActionsModule } from './modules/actions';
60
61
  export type { ActiveRun, RunGuards } from './modules/actions/runtime/guards';
61
62
  export type { ScheduleResult, ScheduleRunner, ScheduleTick } from './modules/actions/runtime/schedule';
@@ -1,4 +1,4 @@
1
- import { requestOrigin } from "@plitzi/sdk-server/kernel";
1
+ import { requestOrigin } from "../requestParser.js";
2
2
  //#region src/core/oauth/metadata.ts
3
3
  var AUTHORIZE_PATH = "/authorize";
4
4
  var TOKEN_PATH = "/token";
@@ -37,4 +37,5 @@ export { handleActionCancel } from './transport/cancelHandler';
37
37
  export type { ActiveRun, RunGuards } from './runtime/guards';
38
38
  export type { ScheduleResult, ScheduleRunner, ScheduleTick } from './runtime/schedule';
39
39
  export type { ActionTaskDescriptor } from './taskCatalog';
40
- export type { ActionCredential, ActionDbDriver, ActionKvStore, ActionLookups, ActionRejectRecord, ActionRunRecord, ActionRunRequest, ActionRunResult, ActionsConfig, ActionTask, ActionTaskContext, ActionTaskRegistry, RegisteredTask, ResolvedConnector } from './types';
40
+ export type { TaskRegistryOptions } from './tasks/registry';
41
+ export type { ActionCredential, ActionDbDriver, ActionEmailConfig, ActionEmailDelivery, ActionEmailMessage, ActionEmailTransport, ActionKvStore, ActionLookups, ActionRejectRecord, ActionRunRecord, ActionRunRequest, ActionRunResult, ActionsConfig, ActionTask, ActionTaskContext, ActionTaskRegistry, RegisteredTask, ResolvedConnector } from './types';
@@ -25,7 +25,7 @@ import { handleActionCancel } from "./transport/cancelHandler.js";
25
25
  * the same as having no single-flight at all. Nothing outside this folder needs to know how a run is assembled.
26
26
  */
27
27
  var createActionsModule = (config) => {
28
- const registry = createTaskRegistry(config.tasks, (config.dbDrivers?.length ?? 0) > 0);
28
+ const registry = createTaskRegistry(config.tasks, { db: (config.dbDrivers?.length ?? 0) > 0 });
29
29
  const { runAction } = createActionRunner(config, registry, config.fetchImpl);
30
30
  /**
31
31
  * Single-flight over the store the deployment already gave the `kv` tasks — its own Redis, table or whatever it
@@ -26,6 +26,7 @@ var actionsModuleFor = (config) => {
26
26
  rateLimit: config.action?.rateLimit,
27
27
  idempotency: config.action?.idempotency,
28
28
  dbDrivers: config.action?.dbDrivers,
29
+ email: config.action?.email,
29
30
  onRun: config.action?.onRun
30
31
  });
31
32
  modules.set(config, module);
@@ -1,6 +1,6 @@
1
1
  import { triggerParams } from "./triggers.js";
2
2
  import { hasValidToken } from "@plitzi/sdk-shared/helpers/twigWrapper";
3
- import { parseCron, triggerHasStaleVerify, triggerVerify } from "@plitzi/sdk-shared/actions";
3
+ import { parseCron, readSmtpCredential, triggerHasStaleVerify, triggerVerify } from "@plitzi/sdk-shared/actions";
4
4
  //#region src/modules/actions/runtime/check.ts
5
5
  /** A value the check can reason about: a literal the author typed, not a template resolved at run time. */
6
6
  var literal = (value) => {
@@ -82,6 +82,17 @@ var checkAction = async (entry, deps) => {
82
82
  const connector = literal(params.connector);
83
83
  if (connector && !await lookups.getConnector?.(spaceId, connector)) add("error", `nodes.${node.id}.params.connector`, `This space has no connector called "${connector}"`, "Add it in Connectors, or name one that exists");
84
84
  const identifier = literal(params.credential);
85
+ if (node.action === "email.send") {
86
+ const path = `nodes.${node.id}.params.credential`;
87
+ if (!identifier) {
88
+ add("error", path, "This step names no SMTP server to send through", "Add an SMTP credential and pick it here");
89
+ return;
90
+ }
91
+ const credential = await checkCredential(node, identifier);
92
+ if (!credential) return;
93
+ readSmtpCredential(credential).problems.forEach((problem) => add("error", path, `The credential "${identifier}" is not a usable SMTP server: ${problem.key} — ${problem.message}`));
94
+ return;
95
+ }
85
96
  if (!identifier) return;
86
97
  if (node.action === "db.query") {
87
98
  const credential = await checkCredential(node, identifier, "dsn");
@@ -0,0 +1,39 @@
1
+ import { HostLookup } from '../../../helpers/outboundGuard';
2
+ import { ActionKvStore } from '../types';
3
+ import { ActionEmailConfig, ActionEmailMessage } from '@plitzi/sdk-shared';
4
+ import { SmtpSettings } from '@plitzi/sdk-shared/actions';
5
+ export declare const DEFAULT_EMAIL_DAILY_LIMIT = 200;
6
+ /** Hands one message to the SMTP server of a space, at an address this module already resolved and judged. */
7
+ export type SmtpDelivery = (delivery: {
8
+ /** The IP to connect to. The host name travels separately, as what the TLS certificate must be for. */
9
+ address: string;
10
+ smtp: SmtpSettings;
11
+ message: ActionEmailMessage;
12
+ signal: AbortSignal;
13
+ }) => Promise<void>;
14
+ /** What the `email.send` task sends through: the policy around a space's own SMTP server, and the connection. */
15
+ export type ActionEmailSender = {
16
+ send: (spaceId: number, smtp: SmtpSettings, message: ActionEmailMessage, signal: AbortSignal) => Promise<void>;
17
+ };
18
+ export type EmailSenderOptions = ActionEmailConfig & {
19
+ /** The server's own store, NOT a space's: a flow must not be able to reach the counter that limits it. */
20
+ kv: ActionKvStore;
21
+ lookup?: HostLookup;
22
+ deliver?: SmtpDelivery;
23
+ now?: () => Date;
24
+ };
25
+ /**
26
+ * Sends a flow's message under the deployment's rules, in this order:
27
+ *
28
+ * 1. **Where it goes is settled.** A deployment's own `transport` owns its connection and receives the message as it
29
+ * is. Without one, the message goes to the SMTP server the space's credential names, and the host is resolved and
30
+ * every address judged first: a credential is typed by a customer and the connection starts inside the
31
+ * deployment's network, so `localhost` or an address that resolves to `10.0.0.5` is refused unless the deployment
32
+ * allowed it. The connection then goes to the address that was judged, so a name that answers differently a second
33
+ * later cannot be how it gets in.
34
+ * 2. **The message is counted against the space's day.** Before it leaves, and never given back: a failing server
35
+ * still spends the allowance, or retrying against an outage would be how the limit is walked past. A host refused
36
+ * in step 1 spends nothing, because nothing was attempted.
37
+ * 3. **The message is delivered.**
38
+ */
39
+ export declare const createEmailSender: ({ kv, dailyLimitPerSpace, allowPrivateHosts, transport, lookup, deliver, now }: EmailSenderOptions) => ActionEmailSender;
@@ -0,0 +1,91 @@
1
+ import { isBlockedHost } from "../../../helpers/outboundGuard.js";
2
+ import { lookup } from "node:dns/promises";
3
+ var DAY_SECONDS = 86400;
4
+ var CONNECTION_TIMEOUT_MS = 1e4;
5
+ var SOCKET_TIMEOUT_MS = 2e4;
6
+ var resolveAll = (hostname) => lookup(hostname, {
7
+ all: true,
8
+ verbatim: true
9
+ });
10
+ /** Loaded on the first message, so a server whose flows send no mail never loads a mail client to find that out. */
11
+ var deliverWithNodemailer = async ({ address, smtp, message, signal }) => {
12
+ const { createTransport } = await import("nodemailer");
13
+ const transport = createTransport({
14
+ host: address,
15
+ port: smtp.port,
16
+ secure: smtp.security === "tls",
17
+ requireTLS: smtp.security === "starttls",
18
+ ignoreTLS: smtp.security === "none",
19
+ tls: { servername: smtp.host },
20
+ ...smtp.username ? { auth: {
21
+ user: smtp.username,
22
+ pass: smtp.password
23
+ } } : {},
24
+ connectionTimeout: CONNECTION_TIMEOUT_MS,
25
+ greetingTimeout: CONNECTION_TIMEOUT_MS,
26
+ socketTimeout: SOCKET_TIMEOUT_MS
27
+ });
28
+ const abort = () => transport.close();
29
+ signal.addEventListener("abort", abort, { once: true });
30
+ try {
31
+ await transport.sendMail({
32
+ from: {
33
+ name: smtp.fromName,
34
+ address: smtp.fromEmail
35
+ },
36
+ to: message.to,
37
+ subject: message.subject,
38
+ text: message.text,
39
+ ...message.replyTo ? { replyTo: message.replyTo } : {}
40
+ });
41
+ } finally {
42
+ signal.removeEventListener("abort", abort);
43
+ transport.close();
44
+ }
45
+ };
46
+ /**
47
+ * Sends a flow's message under the deployment's rules, in this order:
48
+ *
49
+ * 1. **Where it goes is settled.** A deployment's own `transport` owns its connection and receives the message as it
50
+ * is. Without one, the message goes to the SMTP server the space's credential names, and the host is resolved and
51
+ * every address judged first: a credential is typed by a customer and the connection starts inside the
52
+ * deployment's network, so `localhost` or an address that resolves to `10.0.0.5` is refused unless the deployment
53
+ * allowed it. The connection then goes to the address that was judged, so a name that answers differently a second
54
+ * later cannot be how it gets in.
55
+ * 2. **The message is counted against the space's day.** Before it leaves, and never given back: a failing server
56
+ * still spends the allowance, or retrying against an outage would be how the limit is walked past. A host refused
57
+ * in step 1 spends nothing, because nothing was attempted.
58
+ * 3. **The message is delivered.**
59
+ */
60
+ var createEmailSender = ({ kv, dailyLimitPerSpace = 200, allowPrivateHosts = false, transport, lookup = resolveAll, deliver = deliverWithNodemailer, now = () => /* @__PURE__ */ new Date() }) => {
61
+ const viaSmtp = async (smtp) => {
62
+ let addresses;
63
+ try {
64
+ addresses = await lookup(smtp.host);
65
+ } catch {
66
+ throw new Error(`The SMTP host "${smtp.host}" does not resolve`);
67
+ }
68
+ const [first] = addresses;
69
+ if (addresses.length === 0) throw new Error(`The SMTP host "${smtp.host}" does not resolve`);
70
+ if (!allowPrivateHosts && (isBlockedHost(smtp.host) || addresses.some(({ address }) => isBlockedHost(address)))) throw new Error(`The SMTP host "${smtp.host}" is on a private network, which this server does not connect to`);
71
+ return (delivery) => deliver({
72
+ address: first.address,
73
+ smtp: delivery.smtp,
74
+ message: delivery.message,
75
+ signal: delivery.signal
76
+ });
77
+ };
78
+ return { send: async (spaceId, smtp, message, signal) => {
79
+ const deliverMessage = transport ?? await viaSmtp(smtp);
80
+ const day = now().toISOString().slice(0, 10);
81
+ if (await kv.increment(`email-limit:${spaceId}:${day}`, 1, 2 * DAY_SECONDS) > dailyLimitPerSpace) throw new Error(`This space has sent its ${dailyLimitPerSpace} emails for today`);
82
+ await deliverMessage({
83
+ spaceId,
84
+ smtp,
85
+ message,
86
+ signal
87
+ });
88
+ } };
89
+ };
90
+ //#endregion
91
+ export { createEmailSender };
@@ -0,0 +1 @@
1
+ export {};
@@ -4,6 +4,7 @@ import { createKvStore } from "./kvStore.js";
4
4
  import { resolveLimits } from "./limits.js";
5
5
  import { createMemoryKv } from "./memoryKv.js";
6
6
  import { namespaceKv } from "./namespaceKv.js";
7
+ import { createEmailSender } from "./email.js";
7
8
  import { createRedactor, projectUser } from "./scope.js";
8
9
  import { precheckRun } from "./precheck.js";
9
10
  import onAbort from "../../../helpers/onAbort.js";
@@ -200,6 +201,10 @@ var runNode = async (node, scope, registry, buildContext) => {
200
201
  */
201
202
  var createActionRunner = (config, registry, baseFetch = fetch) => {
202
203
  const kv = createKvStore(config.kv ?? createMemoryKv());
204
+ const emailSender = createEmailSender({
205
+ ...config.email,
206
+ kv
207
+ });
203
208
  /** Never allowed to fail a run: a logging outage must not take an action down, the same rule metering follows. */
204
209
  const record = async (entry) => {
205
210
  try {
@@ -260,6 +265,7 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
260
265
  fetch: runFetch,
261
266
  kv: scopedKv,
262
267
  dbDrivers: config.dbDrivers ?? [],
268
+ email: emailSender,
263
269
  emit: (chunk) => request.emit?.(redact(chunk))
264
270
  });
265
271
  const trace = [];
@@ -280,7 +286,13 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
280
286
  spaceId: request.spaceId,
281
287
  environment: request.environment,
282
288
  trigger: request.trigger,
283
- runId
289
+ runId,
290
+ /**
291
+ * The moment the run started, as an ISO string — and the same moment for every step. A check that asks the
292
+ * time and the write it guards cannot straddle midnight, and a template reads it in the zone it cares about
293
+ * with `{{ now|date('Y-m-d', 'Europe/Madrid') }}`.
294
+ */
295
+ now: new Date(startedAt).toISOString()
284
296
  };
285
297
  let status = "completed";
286
298
  let returned;
@@ -2,7 +2,9 @@ import { ActionTask } from '../types';
2
2
  /**
3
3
  * The tasks `sdk-server` itself ships: the ones that are mechanism rather than policy.
4
4
  *
5
- * Anything needing an account somewhere — email, AI, object storage — is the DEPLOYMENT's, registered through
6
- * `actions.tasks`. A server with no mail transport must not offer a step whose only possible outcome is failure.
5
+ * Anything needing an account somewhere the deployment owns — AI, object storage — is the DEPLOYMENT's, registered
6
+ * through `actions.tasks`. `email.send` is shipped because the account is the SPACE's: it sends through an SMTP server
7
+ * the space holds as a credential, so every server can offer it. `db.query` sits in between — the task is shipped,
8
+ * the engine driver is the deployment's, and the registry offers it only when one was given.
7
9
  */
8
10
  export declare const builtinTasks: ActionTask<Record<string, unknown>>[];
@@ -1,5 +1,6 @@
1
1
  import { authTasks } from "./auth.js";
2
2
  import { connectorTasks } from "./connector.js";
3
+ import { emailTasks } from "./email.js";
3
4
  import { flowTasks, streamTasks } from "./flow.js";
4
5
  import { httpTasks } from "./http.js";
5
6
  import { kvTasks } from "./kv.js";
@@ -8,8 +9,10 @@ import { transformTasks } from "./transform.js";
8
9
  /**
9
10
  * The tasks `sdk-server` itself ships: the ones that are mechanism rather than policy.
10
11
  *
11
- * Anything needing an account somewhere — email, AI, object storage — is the DEPLOYMENT's, registered through
12
- * `actions.tasks`. A server with no mail transport must not offer a step whose only possible outcome is failure.
12
+ * Anything needing an account somewhere the deployment owns — AI, object storage — is the DEPLOYMENT's, registered
13
+ * through `actions.tasks`. `email.send` is shipped because the account is the SPACE's: it sends through an SMTP server
14
+ * the space holds as a credential, so every server can offer it. `db.query` sits in between — the task is shipped,
15
+ * the engine driver is the deployment's, and the registry offers it only when one was given.
13
16
  */
14
17
  var builtinTasks = [
15
18
  ...flowTasks,
@@ -18,6 +21,7 @@ var builtinTasks = [
18
21
  ...connectorTasks,
19
22
  ...authTasks,
20
23
  ...kvTasks,
24
+ ...emailTasks,
21
25
  ...streamTasks
22
26
  ];
23
27
  //#endregion
@@ -0,0 +1,2 @@
1
+ import { ActionTask } from '../types';
2
+ export declare const emailTasks: ActionTask<Record<string, unknown>>[];
@@ -0,0 +1,67 @@
1
+ import { isSingleEmailAddress, readSmtpCredential } from "@plitzi/sdk-shared/actions";
2
+ //#region src/modules/actions/tasks/email.ts
3
+ var MAX_SUBJECT = 200;
4
+ var MAX_TEXT = 2e4;
5
+ var emailTasks = [{
6
+ namespace: "email",
7
+ action: "send",
8
+ title: "Send Email",
9
+ description: "Sends a plain-text message through an SMTP server this space holds as a credential",
10
+ params: {
11
+ credential: {
12
+ type: "text",
13
+ canBind: false,
14
+ defaultValue: "",
15
+ label: "SMTP server (credential)",
16
+ credentialProvider: "smtp"
17
+ },
18
+ to: {
19
+ type: "text",
20
+ canBind: true,
21
+ defaultValue: "",
22
+ label: "To"
23
+ },
24
+ subject: {
25
+ type: "text",
26
+ canBind: true,
27
+ defaultValue: "",
28
+ label: "Subject"
29
+ },
30
+ text: {
31
+ type: "codemirror-text",
32
+ canBind: true,
33
+ defaultValue: "",
34
+ label: "Text"
35
+ },
36
+ replyTo: {
37
+ type: "text",
38
+ canBind: true,
39
+ defaultValue: "",
40
+ label: "Reply to"
41
+ }
42
+ },
43
+ run: async ({ credential, to, subject, text, replyTo }, ctx) => {
44
+ const identifier = credential.trim();
45
+ if (identifier === "") throw new Error("This step names no SMTP credential to send through — add one in Credentials and pick it here");
46
+ const recipient = to.trim();
47
+ if (!isSingleEmailAddress(recipient)) throw new Error("The recipient is not one email address");
48
+ const answerTo = replyTo.trim();
49
+ if (answerTo !== "" && !isSingleEmailAddress(answerTo)) throw new Error("The reply-to is not one email address");
50
+ const title = subject.trim();
51
+ if (title === "" || /[\r\n]/.test(title) || title.length > MAX_SUBJECT) throw new Error(`The subject must be one line of 1 to ${MAX_SUBJECT} characters`);
52
+ if (text.trim() === "" || text.length > MAX_TEXT) throw new Error(`The text must have 1 to ${MAX_TEXT} characters`);
53
+ const stored = await ctx.credential(identifier);
54
+ if (!stored) throw new Error(`This space has no SMTP credential called "${identifier}" — add it in Credentials`);
55
+ const reading = readSmtpCredential(stored);
56
+ if (!("settings" in reading)) throw new Error(`Credential "${identifier}" is not a usable SMTP server: ${reading.problems.map((problem) => `${problem.key} — ${problem.message}`).join("; ")}`);
57
+ await ctx.email.send(ctx.spaceId, reading.settings, {
58
+ to: recipient,
59
+ subject: title,
60
+ text,
61
+ ...answerTo === "" ? {} : { replyTo: answerTo }
62
+ }, ctx.signal);
63
+ return { sent: true };
64
+ }
65
+ }];
66
+ //#endregion
67
+ export { emailTasks };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,5 +1,10 @@
1
1
  import { ActionTask, ActionTaskRegistry } from '../types';
2
2
  export declare const taskName: (task: Pick<ActionTask, "namespace" | "action">) => string;
3
+ /** The shipped tasks that are only real when the deployment supplied what they run through. */
4
+ export type TaskRegistryOptions = {
5
+ /** At least one database driver is registered, so `db.query` has an engine to run against. */
6
+ db?: boolean;
7
+ };
3
8
  /**
4
9
  * Builds the set of tasks this server can run.
5
10
  *
@@ -7,4 +12,4 @@ export declare const taskName: (task: Pick<ActionTask, "namespace" | "action">)
7
12
  * name is unreachable from any document, and one shadowing a built-in silently changes what every existing action
8
13
  * in that deployment does. Both are invisible until a run misbehaves in production.
9
14
  */
10
- export declare const createTaskRegistry: (custom?: ActionTask<never>[], hasDbDrivers?: boolean) => ActionTaskRegistry;
15
+ export declare const createTaskRegistry: (custom?: ActionTask<never>[], { db }?: TaskRegistryOptions) => ActionTaskRegistry;
@@ -11,9 +11,9 @@ var reservedNamespaces = new Set([...builtinTasks, ...dbTasks].map((task) => tas
11
11
  * name is unreachable from any document, and one shadowing a built-in silently changes what every existing action
12
12
  * in that deployment does. Both are invisible until a run misbehaves in production.
13
13
  */
14
- var createTaskRegistry = (custom = [], hasDbDrivers = false) => {
14
+ var createTaskRegistry = (custom = [], { db = false } = {}) => {
15
15
  const tasks = /* @__PURE__ */ new Map();
16
- (hasDbDrivers ? [...builtinTasks, ...dbTasks] : builtinTasks).forEach((task) => tasks.set(taskName(task), {
16
+ (db ? [...builtinTasks, ...dbTasks] : builtinTasks).forEach((task) => tasks.set(taskName(task), {
17
17
  ...task,
18
18
  name: taskName(task)
19
19
  }));
@@ -9,7 +9,7 @@ export type ActionCatalogDeps = {
9
9
  *
10
10
  * The editor asks the SERVER what steps exist instead of holding a hardcoded list, and that is the whole reason a
11
11
  * self-hoster's own task appears in their builder with no fork: registering it is publishing it. It also means a
12
- * deployment with no mail transport never offers `email.send` — the catalog is what that deployment actually has,
12
+ * deployment with no database driver never offers `db.query` — the catalog is what that deployment actually has,
13
13
  * not what the SDK could imagine.
14
14
  *
15
15
  * Session-gated by the pipeline: the catalog names a deployment's server-side capabilities, which is not something
@@ -5,7 +5,7 @@ import { describeCatalog } from "../taskCatalog.js";
5
5
  *
6
6
  * The editor asks the SERVER what steps exist instead of holding a hardcoded list, and that is the whole reason a
7
7
  * self-hoster's own task appears in their builder with no fork: registering it is publishing it. It also means a
8
- * deployment with no mail transport never offers `email.send` — the catalog is what that deployment actually has,
8
+ * deployment with no database driver never offers `db.query` — the catalog is what that deployment actually has,
9
9
  * not what the SDK could imagine.
10
10
  *
11
11
  * Session-gated by the pipeline: the catalog names a deployment's server-side capabilities, which is not something
@@ -1,4 +1,5 @@
1
- import { ActionEntry, ActionLimits, ActionRejectRecord, ActionRunRecord, ActionRunStatus, ActionTriggerType, ConnectorManifest, Environment, InteractionCallbackParam, InteractionNode, InteractionNodeStatus, SpaceRevision, SSRUser } from '@plitzi/sdk-shared';
1
+ import { ActionEmailSender } from './runtime/email';
2
+ import { ActionEmailConfig, ActionEntry, ActionLimits, ActionRejectRecord, ActionRunRecord, ActionRunStatus, ActionTriggerType, ConnectorManifest, Environment, InteractionCallbackParam, InteractionNode, InteractionNodeStatus, SpaceRevision, SSRUser } from '@plitzi/sdk-shared';
2
3
  /** Resolved secret material, as the connector engine already models it. */
3
4
  export type ActionCredential = Record<string, string>;
4
5
  /**
@@ -109,6 +110,8 @@ export type ActionTaskContext = {
109
110
  kv: ActionKvStore;
110
111
  /** The database engines this deployment registered, for the `db.query` task. */
111
112
  dbDrivers: ActionDbDriver[];
113
+ /** How `email.send` reaches a space's SMTP server, under the deployment's limits. */
114
+ email: ActionEmailSender;
112
115
  /** Pushes a `data` frame to a streaming caller. A no-op when nobody negotiated a stream. */
113
116
  emit: (chunk: unknown) => void;
114
117
  };
@@ -162,6 +165,8 @@ export type ActionsConfig = {
162
165
  };
163
166
  /** Database engines this deployment lets a flow reach. Empty → the `db.query` task is not offered at all. */
164
167
  dbDrivers?: ActionDbDriver[];
168
+ /** The limits on `email.send`: messages per space per day, and whether an SMTP host may be on a private network. */
169
+ email?: ActionEmailConfig;
165
170
  /**
166
171
  * Called once per run that started, for a deployment that keeps a record.
167
172
  *
@@ -197,7 +202,7 @@ export type ActionsConfig = {
197
202
  };
198
203
  /** Re-exported so the module's own files import one place, and a deployment writing an `onRun` or an `onReject`
199
204
  * sees the same shapes the module emits. */
200
- export type { ActionRejectRecord, ActionRunRecord } from '@plitzi/sdk-shared';
205
+ export type { ActionEmailConfig, ActionEmailDelivery, ActionEmailMessage, ActionEmailTransport, ActionRejectRecord, ActionRunRecord } from '@plitzi/sdk-shared';
201
206
  export type ResolvedActionLimits = Required<ActionLimits>;
202
207
  export type ActionRunRequest = {
203
208
  entry: ActionEntry;
@@ -1,5 +1,5 @@
1
- import { getByPath } from "./getByPath.js";
2
1
  import { assertOutboundAllowed } from "../../helpers/outboundGuard.js";
2
+ import { getByPath } from "./getByPath.js";
3
3
  import { processTwig } from "@plitzi/sdk-shared/helpers/twigWrapper";
4
4
  import { BODYLESS_METHODS, DEFAULT_READ_ENDPOINT, EMPTY_RESPONSE_METHODS } from "@plitzi/sdk-shared/connectors";
5
5
  //#region src/modules/connectors/engine.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plitzi/sdk-server",
3
- "version": "0.35.3",
3
+ "version": "0.35.5",
4
4
  "license": "AGPL-3.0",
5
5
  "files": [
6
6
  "dist",
@@ -58,11 +58,12 @@
58
58
  "type": "module",
59
59
  "sideEffects": false,
60
60
  "dependencies": {
61
- "@plitzi/plitzi-sdk": "0.35.3",
62
- "@plitzi/sdk-shared": "0.35.3",
61
+ "@plitzi/plitzi-sdk": "0.35.5",
62
+ "@plitzi/sdk-shared": "0.35.5",
63
63
  "ejs": "^6.0.1",
64
64
  "esbuild": "^0.28.2",
65
- "jsonwebtoken": "^9.0.3"
65
+ "jsonwebtoken": "^9.0.3",
66
+ "nodemailer": "^10.0.9"
66
67
  },
67
68
  "peerDependencies": {
68
69
  "mysql2": "^3",
@@ -78,6 +79,7 @@
78
79
  "@types/ejs": "^3.1.5",
79
80
  "@types/jsonwebtoken": "^9.0.10",
80
81
  "@types/node": "^26.5.1",
82
+ "@types/nodemailer": "^8.0.1",
81
83
  "@types/react": "^19.3.0",
82
84
  "@vitejs/plugin-react": "^6.1.1",
83
85
  "@vitest/coverage-v8": "^5.0.0",