@plitzi/sdk-server 0.35.4 → 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,14 @@
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
+
3
12
  ## 0.35.4
4
13
 
5
14
  ### Patch Changes
package/dist/actions.d.ts CHANGED
@@ -56,7 +56,7 @@ export { createKvStore } from './modules/actions/runtime/kvStore';
56
56
  */
57
57
  export { createRunLogger, createRejectLogger } from './modules/actions/runtime/runLogger';
58
58
  export type { TaskRegistryOptions } from './modules/actions/tasks/registry';
59
- export type { ActionCredential, ActionDbDriver, ActionEmailAdapter, ActionEmailMessage, ActionKvAdapter, ActionKvStore, ActionLookups, ActionRejectRecord, ActionRunRecord, ActionRunRequest, ActionRunResult, ActionsConfig, ActionTask, ActionTaskContext, ActionTaskRegistry, RegisteredTask, ResolvedConnector } from './modules/actions/types';
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';
60
60
  export type { ActionsModule } from './modules/actions';
61
61
  export type { ActiveRun, RunGuards } from './modules/actions/runtime/guards';
62
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";
@@ -38,4 +38,4 @@ 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
40
  export type { TaskRegistryOptions } from './tasks/registry';
41
- export type { ActionCredential, ActionDbDriver, ActionEmailAdapter, ActionEmailMessage, ActionKvStore, ActionLookups, ActionRejectRecord, ActionRunRecord, ActionRunRequest, ActionRunResult, ActionsConfig, ActionTask, ActionTaskContext, ActionTaskRegistry, RegisteredTask, ResolvedConnector } from './types';
41
+ export type { ActionCredential, ActionDbDriver, ActionEmailConfig, ActionEmailDelivery, ActionEmailMessage, ActionEmailTransport, ActionKvStore, ActionLookups, ActionRejectRecord, ActionRunRecord, ActionRunRequest, ActionRunResult, ActionsConfig, ActionTask, ActionTaskContext, ActionTaskRegistry, RegisteredTask, ResolvedConnector } from './types';
@@ -25,10 +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, {
29
- db: (config.dbDrivers?.length ?? 0) > 0,
30
- email: config.email !== void 0
31
- });
28
+ const registry = createTaskRegistry(config.tasks, { db: (config.dbDrivers?.length ?? 0) > 0 });
32
29
  const { runAction } = createActionRunner(config, registry, config.fetchImpl);
33
30
  /**
34
31
  * Single-flight over the store the deployment already gave the `kv` tasks — its own Redis, table or whatever it
@@ -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,7 +265,7 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
260
265
  fetch: runFetch,
261
266
  kv: scopedKv,
262
267
  dbDrivers: config.dbDrivers ?? [],
263
- email: config.email,
268
+ email: emailSender,
264
269
  emit: (chunk) => request.emit?.(redact(chunk))
265
270
  });
266
271
  const trace = [];
@@ -2,8 +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 — AI, object storage — is the DEPLOYMENT's, registered through
6
- * `actions.tasks`. `db.query` and `email.send` sit in between: the task is shipped because its rules are the same
7
- * everywhere, the driver or transport is the deployment's, and the registry offers the task only when one was given.
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.
8
9
  */
9
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,9 +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 — AI, object storage — is the DEPLOYMENT's, registered through
12
- * `actions.tasks`. `db.query` and `email.send` sit in between: the task is shipped because its rules are the same
13
- * everywhere, the driver or transport is the deployment's, and the registry offers the task only when one was given.
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.
14
16
  */
15
17
  var builtinTasks = [
16
18
  ...flowTasks,
@@ -19,6 +21,7 @@ var builtinTasks = [
19
21
  ...connectorTasks,
20
22
  ...authTasks,
21
23
  ...kvTasks,
24
+ ...emailTasks,
22
25
  ...streamTasks
23
26
  ];
24
27
  //#endregion
@@ -1,19 +1,20 @@
1
+ import { isSingleEmailAddress, readSmtpCredential } from "@plitzi/sdk-shared/actions";
1
2
  //#region src/modules/actions/tasks/email.ts
2
- /**
3
- * One address, and nothing a mail header could be split on.
4
- *
5
- * Stricter than the standard on purpose: a comma or a semicolon is how one recipient becomes a hundred, and a line
6
- * break is how a subject becomes a second header. A flow renders what a visitor typed, so this is where that stops.
7
- */
8
- var ADDRESS = /^[^\s@,;<>"]+@[^\s@,;<>"]+\.[^\s@,;<>"]+$/;
9
3
  var MAX_SUBJECT = 200;
10
4
  var MAX_TEXT = 2e4;
11
5
  var emailTasks = [{
12
6
  namespace: "email",
13
7
  action: "send",
14
8
  title: "Send Email",
15
- description: "Sends a plain-text message through the transport this deployment configured",
9
+ description: "Sends a plain-text message through an SMTP server this space holds as a credential",
16
10
  params: {
11
+ credential: {
12
+ type: "text",
13
+ canBind: false,
14
+ defaultValue: "",
15
+ label: "SMTP server (credential)",
16
+ credentialProvider: "smtp"
17
+ },
17
18
  to: {
18
19
  type: "text",
19
20
  canBind: true,
@@ -39,22 +40,26 @@ var emailTasks = [{
39
40
  label: "Reply to"
40
41
  }
41
42
  },
42
- run: async ({ to, subject, text, replyTo }, ctx) => {
43
- if (!ctx.email) throw new Error("This server has no email transport");
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");
44
46
  const recipient = to.trim();
45
- if (!ADDRESS.test(recipient)) throw new Error("The recipient is not one email address");
47
+ if (!isSingleEmailAddress(recipient)) throw new Error("The recipient is not one email address");
46
48
  const answerTo = replyTo.trim();
47
- if (answerTo !== "" && !ADDRESS.test(answerTo)) throw new Error("The reply-to is not one email address");
49
+ if (answerTo !== "" && !isSingleEmailAddress(answerTo)) throw new Error("The reply-to is not one email address");
48
50
  const title = subject.trim();
49
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`);
50
52
  if (text.trim() === "" || text.length > MAX_TEXT) throw new Error(`The text must have 1 to ${MAX_TEXT} characters`);
51
- await ctx.email.send({
52
- spaceId: ctx.spaceId,
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, {
53
58
  to: recipient,
54
59
  subject: title,
55
60
  text,
56
61
  ...answerTo === "" ? {} : { replyTo: answerTo }
57
- });
62
+ }, ctx.signal);
58
63
  return { sent: true };
59
64
  }
60
65
  }];
@@ -4,8 +4,6 @@ export declare const taskName: (task: Pick<ActionTask, "namespace" | "action">)
4
4
  export type TaskRegistryOptions = {
5
5
  /** At least one database driver is registered, so `db.query` has an engine to run against. */
6
6
  db?: boolean;
7
- /** An email transport is configured, so `email.send` has somewhere to hand a message. */
8
- email?: boolean;
9
7
  };
10
8
  /**
11
9
  * Builds the set of tasks this server can run.
@@ -14,4 +12,4 @@ export type TaskRegistryOptions = {
14
12
  * name is unreachable from any document, and one shadowing a built-in silently changes what every existing action
15
13
  * in that deployment does. Both are invisible until a run misbehaves in production.
16
14
  */
17
- export declare const createTaskRegistry: (custom?: ActionTask<never>[], { db, email }?: TaskRegistryOptions) => ActionTaskRegistry;
15
+ export declare const createTaskRegistry: (custom?: ActionTask<never>[], { db }?: TaskRegistryOptions) => ActionTaskRegistry;
@@ -1,14 +1,9 @@
1
1
  import { builtinTasks } from "./builtins.js";
2
2
  import { dbTasks } from "./db.js";
3
- import { emailTasks } from "./email.js";
4
3
  //#region src/modules/actions/tasks/registry.ts
5
4
  var NAME_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
6
5
  var taskName = (task) => `${task.namespace}.${task.action}`;
7
- var reservedNamespaces = new Set([
8
- ...builtinTasks,
9
- ...dbTasks,
10
- ...emailTasks
11
- ].map((task) => task.namespace));
6
+ var reservedNamespaces = new Set([...builtinTasks, ...dbTasks].map((task) => task.namespace));
12
7
  /**
13
8
  * Builds the set of tasks this server can run.
14
9
  *
@@ -16,13 +11,9 @@ var reservedNamespaces = new Set([
16
11
  * name is unreachable from any document, and one shadowing a built-in silently changes what every existing action
17
12
  * in that deployment does. Both are invisible until a run misbehaves in production.
18
13
  */
19
- var createTaskRegistry = (custom = [], { db = false, email = false } = {}) => {
14
+ var createTaskRegistry = (custom = [], { db = false } = {}) => {
20
15
  const tasks = /* @__PURE__ */ new Map();
21
- [
22
- ...builtinTasks,
23
- ...db ? dbTasks : [],
24
- ...email ? emailTasks : []
25
- ].forEach((task) => tasks.set(taskName(task), {
16
+ (db ? [...builtinTasks, ...dbTasks] : builtinTasks).forEach((task) => tasks.set(taskName(task), {
26
17
  ...task,
27
18
  name: taskName(task)
28
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 { ActionEmailAdapter, 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,8 +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[];
112
- /** The transport this deployment gave the `email.send` task. Absent → that task is not offered at all. */
113
- email?: ActionEmailAdapter;
113
+ /** How `email.send` reaches a space's SMTP server, under the deployment's limits. */
114
+ email: ActionEmailSender;
114
115
  /** Pushes a `data` frame to a streaming caller. A no-op when nobody negotiated a stream. */
115
116
  emit: (chunk: unknown) => void;
116
117
  };
@@ -164,8 +165,8 @@ export type ActionsConfig = {
164
165
  };
165
166
  /** Database engines this deployment lets a flow reach. Empty → the `db.query` task is not offered at all. */
166
167
  dbDrivers?: ActionDbDriver[];
167
- /** How a flow's mail leaves this server. Absent → the `email.send` task is not offered at all. */
168
- email?: ActionEmailAdapter;
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;
169
170
  /**
170
171
  * Called once per run that started, for a deployment that keeps a record.
171
172
  *
@@ -201,7 +202,7 @@ export type ActionsConfig = {
201
202
  };
202
203
  /** Re-exported so the module's own files import one place, and a deployment writing an `onRun` or an `onReject`
203
204
  * sees the same shapes the module emits. */
204
- export type { ActionEmailAdapter, ActionEmailMessage, ActionRejectRecord, ActionRunRecord } from '@plitzi/sdk-shared';
205
+ export type { ActionEmailConfig, ActionEmailDelivery, ActionEmailMessage, ActionEmailTransport, ActionRejectRecord, ActionRunRecord } from '@plitzi/sdk-shared';
205
206
  export type ResolvedActionLimits = Required<ActionLimits>;
206
207
  export type ActionRunRequest = {
207
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.4",
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.4",
62
- "@plitzi/sdk-shared": "0.35.4",
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",