@wfgraph/plugins 3.1.1 → 4.0.0

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/index.js CHANGED
@@ -1,14 +1,18 @@
1
- import { n as getClerkApiErrorMessage, r as toClerkApiUser, t as createClerkBackendClient } from "./client-BEcFnbOB.js";
2
- import { t as describeLinearFailure } from "./errors-C4i6-DQE.js";
3
- import { i as sendResendEmail, t as describeResendFailure } from "./client-C2NIQxhb.js";
4
- import { n as describeSlackFailure, t as callSlack } from "./client-DDDCNLId.js";
5
- import { n as describeTwilioFailure, t as createTwilioMessage } from "./client-LRwmIiPU.js";
6
- import { StepFailure, defineIntegration, readAs } from "@wfgraph/core/plugin";
7
- import { omitBy } from "es-toolkit/object";
8
- import { isNil } from "es-toolkit/predicate";
1
+ import { t as omitUndefined } from "./omit-undefined-CnDMZgmp.js";
2
+ import { n as getClerkApiErrorMessage, r as toClerkApiUser, t as createClerkBackendClient } from "./client-B0dhD5OA.js";
3
+ import { t as describeLinearFailure } from "./errors-DQPf9k4I.js";
4
+ import { a as resolvePostHogHost, n as describePostHogFailure, t as captureEvent } from "./client-B0M2Cle4.js";
5
+ import { c as sendResendEmail, n as describeResendFailure, r as getResendEmail } from "./client-D3ALurAR.js";
6
+ import { n as describeSlackFailure, t as callSlack } from "./client-BQmwEhSH.js";
7
+ import { n as describeTwilioFailure, t as createTwilioMessage } from "./client-C32OdgmT.js";
8
+ import { SignatureRejected, StepFailure, callExternal, callExternalAsync, defineEvent, defineIntegration, isoTimestampString, parsePayload, readAs, readJsonValue } from "@wfgraph/core/plugin";
9
9
  import { Effect, Result, Schema, SchemaTransformation } from "effect";
10
10
  import { LinearClient } from "@linear/sdk";
11
- //#region src/clerk/metadata.ts
11
+ import { isEmptyObject } from "es-toolkit/predicate";
12
+ import { compact } from "es-toolkit/array";
13
+ import { Webhook } from "svix";
14
+ import { Base64 } from "effect/encoding";
15
+ // src/clerk/metadata.ts
12
16
  /**
13
17
  * The JSON a workflow author types into a metadata field, as Clerk takes it.
14
18
  *
@@ -24,7 +28,7 @@ const readClerkMetadata = readAs(Schema.Record(Schema.String, Schema.Unknown));
24
28
  * wrong answer.
25
29
  */
26
30
  function parseClerkMetadata(value, fieldName) {
27
- if (!value) return Effect.succeed(void 0);
31
+ if (!value) return Effect.undefined;
28
32
  let parsed;
29
33
  try {
30
34
  parsed = JSON.parse(value);
@@ -34,12 +38,11 @@ function parseClerkMetadata(value, fieldName) {
34
38
  const metadata = readClerkMetadata(parsed);
35
39
  return metadata ? Effect.succeed(metadata) : Effect.fail(new StepFailure({ message: `Invalid JSON format for ${fieldName}` }));
36
40
  }
37
- //#endregion
38
- //#region src/clerk/types.ts
41
+ // src/clerk/types.ts
39
42
  /**
40
43
  * Clerk's user resource as the three user-returning steps answer with it: the
41
44
  * primary address picked out of the address list, and the metadata objects left
42
- * behind, since a downstream node has no shape to address them by.
45
+ * behind, because a downstream node has no shape to address them by.
43
46
  */
44
47
  function toClerkUserData(apiUser) {
45
48
  const primaryEmail = apiUser.email_addresses.find((e) => e.id === apiUser.primary_email_address_id);
@@ -52,8 +55,7 @@ function toClerkUserData(apiUser) {
52
55
  updatedAt: apiUser.updated_at
53
56
  };
54
57
  }
55
- //#endregion
56
- //#region src/clerk/index.ts
58
+ // src/clerk/index.ts
57
59
  /**
58
60
  * The Clerk integration: its credentials, its four actions, and what each takes
59
61
  * and gives back.
@@ -61,7 +63,7 @@ function toClerkUserData(apiUser) {
61
63
  * One file, because only the server imports it. The editor gets this plugin's
62
64
  * metadata as JSON over `/api/extensions`, so nothing here reaches a browser
63
65
  * bundle and `@clerk/backend`, which `client.ts` pulls in, costs the browser
64
- * nothing. The icon is the exception, since a React component cannot be
66
+ * nothing. The icon is the exception, because a React component cannot be
65
67
  * serialized: it stays in `ui.ts`, which only the browser imports.
66
68
  */
67
69
  const clerkCredentialFields = { CLERK_SECRET_KEY: {
@@ -77,8 +79,8 @@ const clerkCredentialFields = { CLERK_SECRET_KEY: {
77
79
  /**
78
80
  * The user three of the four actions answer with, flattened out of Clerk's
79
81
  * resource: the primary address is picked out of the address list, and the
80
- * metadata objects stay behind, since a downstream node has no shape to address
81
- * them by.
82
+ * metadata objects stay behind, because a downstream node has no shape to
83
+ * address them by.
82
84
  *
83
85
  * The three Clerk may have nothing to give are `optionalKey(NullOr(...))`, which is
84
86
  * the one spelling that survives an absent key and an explicit null alike. The
@@ -86,10 +88,10 @@ const clerkCredentialFields = { CLERK_SECRET_KEY: {
86
88
  * right question about a missing name.
87
89
  */
88
90
  const clerkUserOutput = {
89
- id: Schema.String.annotate({ description: "User ID" }),
90
- firstName: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "First name" }))),
91
- lastName: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Last name" }))),
92
- primaryEmailAddress: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Primary email address" }))),
91
+ id: Schema.String.annotate({ title: "User ID" }),
92
+ firstName: Schema.optionalKey(Schema.NullOr(Schema.String)),
93
+ lastName: Schema.optionalKey(Schema.NullOr(Schema.String)),
94
+ primaryEmailAddress: Schema.optionalKey(Schema.NullOr(Schema.String)),
93
95
  createdAt: Schema.Number.annotate({ description: "When the user was created, epoch milliseconds" }).check(Schema.isFinite()),
94
96
  updatedAt: Schema.Number.annotate({ description: "When the user was last updated, epoch milliseconds" }).check(Schema.isFinite())
95
97
  };
@@ -115,13 +117,13 @@ const updateUserInput = Schema.Struct({
115
117
  });
116
118
  const updateUserOutput = Schema.Struct(clerkUserOutput);
117
119
  const deleteUserInput = Schema.Struct({ userId: Schema.String });
118
- const deleteUserOutput = Schema.Struct({ deleted: Schema.Boolean.annotate({ description: "Deletion success" }) });
120
+ const deleteUserOutput = Schema.Struct({ deleted: Schema.Boolean.annotate({ title: "Deletion success" }) });
119
121
  const clerk = defineIntegration({
120
122
  type: "clerk",
121
123
  label: "Clerk",
122
124
  description: "User authentication and management",
123
125
  credentials: clerkCredentialFields,
124
- test: async () => (await import("./test-Dsrib2EJ.js")).testClerk,
126
+ test: async () => (await import("./test-CiIt-3_x.js")).testClerk,
125
127
  actions: {
126
128
  "get-user": {
127
129
  label: "Get User",
@@ -211,14 +213,14 @@ const clerk = defineIntegration({
211
213
  const publicMetadata = yield* parseClerkMetadata(input.publicMetadata, "publicMetadata");
212
214
  const privateMetadata = yield* parseClerkMetadata(input.privateMetadata, "privateMetadata");
213
215
  const client = createClerkBackendClient(secretKey);
214
- const createPayload = omitBy({
216
+ const createPayload = omitUndefined({
215
217
  emailAddress: [input.emailAddress],
216
218
  firstName: input.firstName,
217
219
  lastName: input.lastName,
218
220
  password: input.password,
219
221
  publicMetadata,
220
222
  privateMetadata
221
- }, isNil);
223
+ });
222
224
  return yield* bag.step.run("create-user", Effect.tryPromise({
223
225
  try: () => client.users.createUser(createPayload),
224
226
  catch: (error) => new StepFailure({ message: `Failed to create user: ${getClerkApiErrorMessage(error)}` })
@@ -279,12 +281,12 @@ const clerk = defineIntegration({
279
281
  const publicMetadata = yield* parseClerkMetadata(input.publicMetadata, "publicMetadata");
280
282
  const privateMetadata = yield* parseClerkMetadata(input.privateMetadata, "privateMetadata");
281
283
  const client = createClerkBackendClient(secretKey);
282
- const updatePayload = omitBy({
284
+ const updatePayload = omitUndefined({
283
285
  firstName: input.firstName,
284
286
  lastName: input.lastName,
285
287
  publicMetadata,
286
288
  privateMetadata
287
- }, isNil);
289
+ });
288
290
  return yield* bag.step.run("update-user", Effect.tryPromise({
289
291
  try: () => client.users.updateUser(input.userId, updatePayload),
290
292
  catch: (error) => new StepFailure({ message: `Failed to update user: ${getClerkApiErrorMessage(error)}` })
@@ -320,14 +322,12 @@ const clerk = defineIntegration({
320
322
  }
321
323
  }
322
324
  });
323
- //#endregion
324
- //#region src/linear/client.ts
325
+ // src/linear/client.ts
325
326
  /** Production Linear SDK client construction from an API key. */
326
327
  function createLinearClient(apiKey) {
327
328
  return new LinearClient({ apiKey });
328
329
  }
329
- //#endregion
330
- //#region src/linear/index.ts
330
+ // src/linear/index.ts
331
331
  const linearCredentialFields = {
332
332
  LINEAR_API_KEY: {
333
333
  label: "API Key",
@@ -358,9 +358,9 @@ const createTicketInput = Schema.Struct({
358
358
  ticketDescription: Schema.optionalKey(Schema.String)
359
359
  });
360
360
  const createTicketOutput = Schema.Struct({
361
- id: Schema.String.annotate({ description: "Ticket ID" }),
362
- url: Schema.String.annotate({ description: "Ticket URL" }),
363
- title: Schema.String.annotate({ description: "Ticket title" })
361
+ id: Schema.String.annotate({ title: "Ticket ID" }),
362
+ url: Schema.String.annotate({ title: "Ticket URL" }),
363
+ title: Schema.String.annotate({ title: "Ticket title" })
364
364
  });
365
365
  const findIssuesInput = Schema.Struct({
366
366
  linearAssigneeId: Schema.optionalKey(Schema.String),
@@ -394,16 +394,19 @@ const linearStateType = {
394
394
  * as a field beside it does.
395
395
  */
396
396
  const linearIssueSchema = Schema.Struct({
397
- id: Schema.String.annotate({ description: "Issue ID" }),
398
- title: Schema.String.annotate({ description: "Issue title" }),
399
- url: Schema.String.annotate({ description: "Issue URL" }),
400
- state: Schema.String.annotate({ description: "Workflow state name" }),
397
+ id: Schema.String.annotate({ title: "Issue ID" }),
398
+ title: Schema.String.annotate({ title: "Issue title" }),
399
+ url: Schema.String.annotate({ title: "Issue URL" }),
400
+ state: Schema.String.annotate({ title: "Workflow state name" }),
401
401
  priority: Schema.Number.annotate({ description: "Priority, 0 (none) through 4 (low)" }).check(Schema.isFinite()),
402
- assigneeId: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Assigned user ID" })))
402
+ assigneeId: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ title: "Assigned user ID" })))
403
403
  });
404
404
  const findIssuesOutput = Schema.Struct({
405
- issues: Schema.Array(linearIssueSchema).annotate({ description: "Array of issues found" }),
406
- count: Schema.Number.annotate({ description: "Number of issues" }).check(Schema.isFinite())
405
+ issues: Schema.Array(linearIssueSchema).annotate({
406
+ title: "Issues",
407
+ description: "The issues that matched the search."
408
+ }),
409
+ count: Schema.Number.annotate({ title: "Number of issues" }).check(Schema.isFinite())
407
410
  });
408
411
  /**
409
412
  * Everything the Linear SDK does is a Promise that throws, so every call goes
@@ -437,7 +440,7 @@ function buildIssueFilter(input) {
437
440
  if (input.linearTeamId) filter.team = { id: { eq: input.linearTeamId } };
438
441
  if (input.linearStatus && input.linearStatus !== "any") filter.state = { type: { eq: linearStateType[input.linearStatus] } };
439
442
  if (input.linearLabel) filter.labels = { name: { eqIgnoreCase: input.linearLabel } };
440
- return Object.keys(filter).length > 0 ? filter : void 0;
443
+ return isEmptyObject(filter) ? void 0 : filter;
441
444
  }
442
445
  function createLinear(createClient) {
443
446
  return defineIntegration({
@@ -446,7 +449,7 @@ function createLinear(createClient) {
446
449
  description: "Create and manage issues in Linear",
447
450
  credentials: linearCredentialFields,
448
451
  test: async () => {
449
- const { testLinear } = await import("./test-DWVpiQNl.js");
452
+ const { testLinear } = await import("./test-CTWGAckT.js");
450
453
  return (creds) => testLinear(creds, createClient);
451
454
  },
452
455
  actions: {
@@ -479,11 +482,11 @@ function createLinear(createClient) {
479
482
  const client = createClient(apiKey);
480
483
  const teamId = credentials.LINEAR_TEAM_ID || (yield* bag.step.run("first-team", firstTeamId(client)));
481
484
  const issue = yield* bag.step.run("create-ticket", callLinear("Failed to create ticket", async () => {
482
- const created = await client.createIssue({
485
+ const created = await client.createIssue(omitUndefined({
483
486
  title: input.ticketTitle,
484
487
  description: input.ticketDescription,
485
488
  teamId
486
- });
489
+ }));
487
490
  const made = created.issue ? await created.issue : void 0;
488
491
  return made ? {
489
492
  id: made.id,
@@ -560,7 +563,7 @@ function createLinear(createClient) {
560
563
  const client = createClient(apiKey);
561
564
  const issues = yield* bag.step.run("find-issues", Effect.tryPromise({
562
565
  try: async () => {
563
- const found = await client.issues({ filter: buildIssueFilter(input) });
566
+ const found = await client.issues(omitUndefined({ filter: buildIssueFilter(input) }));
564
567
  while (found.pageInfo.hasNextPage) await found.fetchNext();
565
568
  return await Promise.all(found.nodes.map(async (issue) => {
566
569
  const state = issue.state ? await issue.state : void 0;
@@ -586,19 +589,1135 @@ function createLinear(createClient) {
586
589
  });
587
590
  }
588
591
  const linear = createLinear(createLinearClient);
589
- //#endregion
590
- //#region src/resend/index.ts
592
+ // src/posthog/capture.ts
591
593
  /**
592
- * The Resend integration: its credentials, its one action, and what that action
593
- * takes and gives back.
594
+ * The shared `/i/v0/e/` path both PostHog actions take.
595
+ *
596
+ * Handlers stay inline in `defineIntegration` so `bag.input` keeps its
597
+ * contextual type. What they share is the connection, the memoized identity,
598
+ * and turning a client failure into the step's.
599
+ */
600
+ const MISSING_CREDENTIAL = "POSTHOG_PROJECT_API_KEY is not configured. Please add it in Project Integrations.";
601
+ /**
602
+ * The uuid and timestamp an event is sent under, taken in a step of their own.
603
+ *
604
+ * This is what makes a resend safe. PostHog has no idempotency key and
605
+ * deduplicates on `[timestamp, distinct_id, event, uuid]`, so two attempts
606
+ * collapse only when both of these are the same on each. Memoizing them means
607
+ * every attempt -- `callExternal`'s retry inside the step, a resumed run
608
+ * replaying it, Inngest retrying the whole function -- sends bytes identical to
609
+ * the ones that may already have arrived. Taking either inside the send itself
610
+ * would leave two events instead of one.
611
+ *
612
+ * PostHog's dedup runs during background merges, so it is eventual: a duplicate
613
+ * can show in insights before it merges away. The trade is deliberate, because
614
+ * a dropped analytics event is a hole nobody can reconstruct.
615
+ */
616
+ const eventIdentity = Effect.sync(() => ({
617
+ uuid: globalThis.crypto.randomUUID(),
618
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
619
+ }));
620
+ function connectionFrom(credentials) {
621
+ const projectApiKey = credentials.POSTHOG_PROJECT_API_KEY;
622
+ if (!projectApiKey) return Effect.fail(new StepFailure({ message: MISSING_CREDENTIAL }));
623
+ return Effect.succeed({
624
+ projectApiKey,
625
+ host: resolvePostHogHost(credentials.POSTHOG_HOST)
626
+ });
627
+ }
628
+ function captureOrFail(message, connection, event) {
629
+ return captureEvent(connection, event).pipe(Effect.mapError((error) => new StepFailure({ message: `${message}: ${describePostHogFailure(error)}` })));
630
+ }
631
+ // src/oauth-cimd.ts
632
+ /**
633
+ * CIMD public-client token writes.
634
+ *
635
+ * Resend and PostHog both post form bodies to a metadata-document client with
636
+ * no secret and no retry. The provider-specific mapping from a token response
637
+ * to credentials stays in each adapter.
638
+ */
639
+ const oauthErrorSchema = Schema.Struct({
640
+ error: Schema.optionalKey(Schema.String),
641
+ error_description: Schema.optionalKey(Schema.String),
642
+ error_uri: Schema.optionalKey(Schema.String)
643
+ });
644
+ /** A successful revocation has an empty 200 response body. */
645
+ const emptyOAuthRevokeResponseSchema = Schema.Undefined.pipe(Schema.decodeTo(Schema.Literal(true), SchemaTransformation.transform({
646
+ decode: () => true,
647
+ encode: () => void 0
648
+ })));
649
+ function sanitize$1(value, secrets) {
650
+ let sanitized = value;
651
+ for (const secret of secrets) {
652
+ if (secret.length === 0) continue;
653
+ sanitized = sanitized.replaceAll(secret, "[redacted]").replaceAll(encodeURIComponent(secret), "[redacted]");
654
+ }
655
+ return sanitized;
656
+ }
657
+ function safeOAuthError(payload, secrets) {
658
+ const parsed = parsePayload(payload, oauthErrorSchema);
659
+ const code = parsed?.error;
660
+ const safeCode = code !== void 0 && /^[a-z0-9_]{1,100}$/.test(code) ? code : void 0;
661
+ const description = parsed?.error_description;
662
+ const safeDescription = description ? sanitize$1(description, secrets).slice(0, 500) : void 0;
663
+ return compact([safeCode, safeDescription]).join(": ") || "unknown error";
664
+ }
665
+ function describeOAuthFailure$1(system, error, secrets) {
666
+ if (error._tag === "ExternalUnreachable") return `${system} request failed: ${sanitize$1(error.message, secrets)}`;
667
+ if (error._tag === "ExternalRejected") return `${system} request rejected: ${safeOAuthError(error.payload, secrets)}`;
668
+ return `${system} request failed: HTTP ${error.status}`;
669
+ }
670
+ async function requestCimdToken(options) {
671
+ const result = await callExternalAsync(callExternal({
672
+ system: options.system,
673
+ url: options.url,
674
+ method: "POST",
675
+ headers: { "content-type": "application/x-www-form-urlencoded" },
676
+ body: {
677
+ kind: "form",
678
+ value: options.body
679
+ },
680
+ schema: options.schema
681
+ }), (error) => error);
682
+ if (!result.ok) throw new Error(describeOAuthFailure$1(options.system, result.failure, options.secrets));
683
+ return result.data;
684
+ }
685
+ function currentRefreshToken(provider, grant) {
686
+ const refreshToken = grant.tokens.refreshToken;
687
+ if (!refreshToken) throw new Error(`${provider} OAuth refresh requires the current refresh token`);
688
+ return refreshToken;
689
+ }
690
+ // src/posthog/oauth.ts
691
+ const POSTHOG_AUTHORIZE_URL = "https://oauth.posthog.com/oauth/authorize/";
692
+ const POSTHOG_TOKEN_URL = "https://oauth.posthog.com/oauth/token/";
693
+ const POSTHOG_REVOKE_URL = "https://oauth.posthog.com/oauth/revoke/";
694
+ /**
695
+ * The only scope capture needs from the private API: reading the project so
696
+ * the adapter can copy its `api_token`. Naming none would request PostHog's
697
+ * entire vocabulary, and PostHog's consent page has no permission chooser that
698
+ * would narrow that for us.
699
+ */
700
+ const POSTHOG_PROJECT_READ_SCOPE = "project:read";
701
+ const GRANTED_ACCESS_LABEL = "Project access";
702
+ const POSTHOG_REGIONS = [{
703
+ apiHost: "https://us.posthog.com",
704
+ captureHost: "https://us.i.posthog.com"
705
+ }, {
706
+ apiHost: "https://eu.posthog.com",
707
+ captureHost: "https://eu.i.posthog.com"
708
+ }];
709
+ const NonEmptyTrimmedString = Schema.String.pipe(Schema.decodeTo(Schema.String, SchemaTransformation.trim())).check(Schema.isMinLength(1));
710
+ const posthogOAuthTokenResponseSchema = Schema.Struct({
711
+ access_token: Schema.String,
712
+ token_type: Schema.Literal("Bearer"),
713
+ expires_in: Schema.Finite.check(Schema.isGreaterThan(0)),
714
+ refresh_token: Schema.String,
715
+ scope: Schema.optionalKey(Schema.String),
716
+ scoped_teams: Schema.optionalKey(Schema.Array(Schema.Finite))
717
+ });
718
+ const posthogProjectSchema = Schema.Struct({
719
+ id: Schema.Finite,
720
+ name: Schema.String,
721
+ api_token: NonEmptyTrimmedString
722
+ });
723
+ const posthogProjectListSchema = Schema.Struct({ results: Schema.Array(posthogProjectSchema) });
724
+ const NO_PROJECT = "PostHog OAuth could not find a project for this grant.";
725
+ const MANY_PROJECTS = "PostHog OAuth must be scoped to a single project.";
726
+ const NO_API_KEY = "PostHog did not return a project API key for this grant.";
727
+ function soleItem(items) {
728
+ if (items.length === 1) {
729
+ const item = items[0];
730
+ if (item !== void 0) return item;
731
+ }
732
+ if (items.length === 0) throw new Error(NO_PROJECT);
733
+ throw new Error(MANY_PROJECTS);
734
+ }
735
+ function sanitize(value, secrets) {
736
+ let sanitized = value;
737
+ for (const secret of secrets) {
738
+ if (secret.length === 0) continue;
739
+ sanitized = sanitized.replaceAll(secret, "[redacted]").replaceAll(encodeURIComponent(secret), "[redacted]");
740
+ }
741
+ return sanitized;
742
+ }
743
+ function describeApiFailure(error, secrets) {
744
+ if (error._tag === "ExternalUnreadable" && error.status === 200) return NO_API_KEY;
745
+ if (error._tag === "ExternalUnreachable") return `PostHog API request failed: ${sanitize(error.message, secrets)}`;
746
+ return `PostHog API request failed: HTTP ${error.status}`;
747
+ }
748
+ /**
749
+ * Probe the other cloud only when this one said the token is not for it.
750
+ *
751
+ * A GET is safe to repeat, so a 503 is retried against the same host. Treating
752
+ * that, or a timeout, as a region miss would hide a 5xx from the right cloud
753
+ * behind a second request to the wrong one.
754
+ */
755
+ function shouldProbeNextRegion(error) {
756
+ return error._tag === "ExternalRejected" && (error.status === 401 || error.status === 403 || error.status === 404);
757
+ }
758
+ function requestPostHogToken(url, body, schema, secrets) {
759
+ return requestCimdToken({
760
+ system: "PostHog OAuth",
761
+ url,
762
+ body,
763
+ schema,
764
+ secrets
765
+ });
766
+ }
767
+ async function fetchProjectById(region, accessToken, projectId) {
768
+ const result = await callExternalAsync(callExternal({
769
+ system: "PostHog",
770
+ url: `${region.apiHost}/api/projects/${projectId}/`,
771
+ method: "GET",
772
+ headers: { authorization: `Bearer ${accessToken}` },
773
+ schema: posthogProjectSchema
774
+ }), (error) => error);
775
+ if (!result.ok) return result;
776
+ return {
777
+ ok: true,
778
+ project: result.data
779
+ };
780
+ }
781
+ async function fetchSoleListedProject(region, accessToken) {
782
+ const result = await callExternalAsync(callExternal({
783
+ system: "PostHog",
784
+ url: `${region.apiHost}/api/projects/`,
785
+ method: "GET",
786
+ headers: { authorization: `Bearer ${accessToken}` },
787
+ schema: posthogProjectListSchema
788
+ }), (error) => error);
789
+ if (!result.ok) return result;
790
+ return {
791
+ ok: true,
792
+ project: soleItem(result.data.results)
793
+ };
794
+ }
795
+ function credentialsFromProject(project, captureHost) {
796
+ return {
797
+ credentials: {
798
+ POSTHOG_PROJECT_API_KEY: project.api_token,
799
+ POSTHOG_HOST: captureHost
800
+ },
801
+ accountLabel: project.name
802
+ };
803
+ }
804
+ /**
805
+ * US first, then EU. Parallel would spend a request on the wrong cloud on
806
+ * every grant, and a 5xx from the right cloud must not be hidden by trying
807
+ * the other.
808
+ */
809
+ async function resolveProject(accessToken, projectId, secrets) {
810
+ const us = POSTHOG_REGIONS[0];
811
+ const eu = POSTHOG_REGIONS[1];
812
+ const fetch = projectId === void 0 ? (region) => fetchSoleListedProject(region, accessToken) : (region) => fetchProjectById(region, accessToken, projectId);
813
+ const first = await fetch(us);
814
+ if (first.ok) return credentialsFromProject(first.project, us.captureHost);
815
+ if (!shouldProbeNextRegion(first.failure)) throw new Error(describeApiFailure(first.failure, secrets));
816
+ const second = await fetch(eu);
817
+ if (second.ok) return credentialsFromProject(second.project, eu.captureHost);
818
+ throw new Error(describeApiFailure(second.failure, secrets));
819
+ }
820
+ function tokenSet$2(response, credentials, accountLabel) {
821
+ return {
822
+ credentials,
823
+ grantedAccessLabel: GRANTED_ACCESS_LABEL,
824
+ accountLabel,
825
+ tokens: {
826
+ accessToken: response.access_token,
827
+ refreshToken: response.refresh_token,
828
+ expiresAt: new Date(Date.now() + response.expires_in * 1e3).toISOString()
829
+ }
830
+ };
831
+ }
832
+ function projectCredentialsFromGrant(grant) {
833
+ const projectApiKey = grant.credentials.POSTHOG_PROJECT_API_KEY;
834
+ const host = grant.credentials.POSTHOG_HOST;
835
+ if (!(projectApiKey && host)) throw new Error("PostHog OAuth refresh requires the stored project API key and host.");
836
+ return {
837
+ POSTHOG_PROJECT_API_KEY: projectApiKey,
838
+ POSTHOG_HOST: host
839
+ };
840
+ }
841
+ /**
842
+ * Empty `scoped_teams` means list and take the only project. One id means
843
+ * fetch that project. More than one is the same refusal as a list of two.
844
+ */
845
+ function projectIdFromGrant(scopedTeams) {
846
+ if (scopedTeams === void 0 || scopedTeams.length === 0) return;
847
+ return soleItem(scopedTeams);
848
+ }
849
+ const posthogOAuth = {
850
+ label: "PostHog",
851
+ pkce: "S256",
852
+ registerClient: (context) => ({
853
+ clientId: context.metadataDocumentUrl,
854
+ metadataDocument: {
855
+ client_id: context.metadataDocumentUrl,
856
+ client_name: "Workflow Graph",
857
+ client_uri: context.publicUrl,
858
+ redirect_uris: [context.callbackUrl],
859
+ grant_types: ["authorization_code", "refresh_token"],
860
+ response_types: ["code"],
861
+ token_endpoint_auth_method: "none",
862
+ scope: POSTHOG_PROJECT_READ_SCOPE
863
+ }
864
+ }),
865
+ authorize: ({ client, redirectUri, state, codeChallenge }) => {
866
+ const url = new URL(POSTHOG_AUTHORIZE_URL);
867
+ url.searchParams.set("client_id", client.clientId);
868
+ url.searchParams.set("response_type", "code");
869
+ url.searchParams.set("redirect_uri", redirectUri);
870
+ url.searchParams.set("state", state);
871
+ url.searchParams.set("scope", POSTHOG_PROJECT_READ_SCOPE);
872
+ url.searchParams.set("required_access_level", "project");
873
+ url.searchParams.set("code_challenge", codeChallenge);
874
+ url.searchParams.set("code_challenge_method", "S256");
875
+ return url;
876
+ },
877
+ exchange: async ({ client, code, redirectUri, codeVerifier }) => {
878
+ const secrets = [
879
+ client.clientId,
880
+ code,
881
+ codeVerifier
882
+ ];
883
+ const response = await requestPostHogToken(POSTHOG_TOKEN_URL, new URLSearchParams({
884
+ grant_type: "authorization_code",
885
+ client_id: client.clientId,
886
+ code,
887
+ redirect_uri: redirectUri,
888
+ code_verifier: codeVerifier
889
+ }), posthogOAuthTokenResponseSchema, secrets);
890
+ const project = await resolveProject(response.access_token, projectIdFromGrant(response.scoped_teams), [
891
+ ...secrets,
892
+ response.access_token,
893
+ response.refresh_token
894
+ ]);
895
+ return tokenSet$2(response, project.credentials, project.accountLabel);
896
+ },
897
+ refresh: async ({ client, grant }) => {
898
+ const refreshToken = currentRefreshToken("PostHog", grant);
899
+ const credentials = projectCredentialsFromGrant(grant);
900
+ return tokenSet$2(await requestPostHogToken(POSTHOG_TOKEN_URL, new URLSearchParams({
901
+ grant_type: "refresh_token",
902
+ client_id: client.clientId,
903
+ refresh_token: refreshToken
904
+ }), posthogOAuthTokenResponseSchema, [
905
+ client.clientId,
906
+ refreshToken,
907
+ grant.tokens.accessToken
908
+ ]), credentials);
909
+ },
910
+ revoke: async ({ client, grant }) => {
911
+ const refreshToken = currentRefreshToken("PostHog", grant);
912
+ await requestPostHogToken(POSTHOG_REVOKE_URL, new URLSearchParams({
913
+ client_id: client.clientId,
914
+ token: refreshToken,
915
+ token_type_hint: "refresh_token"
916
+ }), emptyOAuthRevokeResponseSchema, [
917
+ client.clientId,
918
+ refreshToken,
919
+ grant.tokens.accessToken
920
+ ]);
921
+ }
922
+ };
923
+ // src/posthog/properties.ts
924
+ /**
925
+ * The JSON a workflow author types into a PostHog property field.
926
+ *
927
+ * Capture Event and Identify Person both offer key-value rows and a JSON box,
928
+ * so the reading of them is written once here rather than twice beside the
929
+ * steps. Capture drops unreadable text so a bad box does not lose the event.
930
+ * Identify fails the step, because a parser miss would otherwise look like
931
+ * "no properties were authored".
932
+ */
933
+ const propertyEntriesSchema = Schema.Array(Schema.Struct({
934
+ name: Schema.String,
935
+ value: Schema.String
936
+ }));
937
+ const decodePropertyEntries = Schema.decodeUnknownResult(propertyEntriesSchema, { errors: "all" });
938
+ function asObject(entries) {
939
+ return entries ?? {};
940
+ }
941
+ function namedEntries(entries) {
942
+ const properties = {};
943
+ for (const entry of entries) if (entry.name.trim()) properties[entry.name] = entry.value;
944
+ return properties;
945
+ }
946
+ function readKeyValueProperties(entriesJson, invalid) {
947
+ let parsed;
948
+ try {
949
+ parsed = JSON.parse(entriesJson);
950
+ } catch (error) {
951
+ if (invalid === "fail") return Effect.fail(new StepFailure({ message: "Properties is not valid JSON." }));
952
+ console.error("[PostHog] Failed to parse properties JSON:", error);
953
+ return Effect.undefined;
954
+ }
955
+ const result = decodePropertyEntries(parsed);
956
+ if (Result.isFailure(result)) {
957
+ if (invalid === "fail") return Effect.fail(new StepFailure({ message: "Properties must be a list of { name, value } entries." }));
958
+ console.error("[PostHog] Properties must be a list of { name, value } entries:", result.failure.message);
959
+ return Effect.undefined;
960
+ }
961
+ return Effect.succeed(namedEntries(result.success));
962
+ }
963
+ function readJsonProperties(text, invalid) {
964
+ let parsed;
965
+ try {
966
+ parsed = JSON.parse(text);
967
+ } catch (error) {
968
+ if (invalid === "fail") return Effect.fail(new StepFailure({ message: "Properties JSON is not valid JSON." }));
969
+ console.error("[PostHog] Failed to parse the properties JSON box:", error);
970
+ return Effect.undefined;
971
+ }
972
+ const json = readJsonValue(parsed);
973
+ if (json === null || typeof json !== "object" || Array.isArray(json)) {
974
+ if (invalid === "fail") return Effect.fail(new StepFailure({ message: "Properties JSON must hold a JSON object of property names to values." }));
975
+ console.error("[PostHog] The properties JSON box must hold a JSON object of property names to values.");
976
+ return Effect.undefined;
977
+ }
978
+ return Effect.succeed(json);
979
+ }
980
+ /**
981
+ * One property bag out of the two fields that feed it.
982
+ *
983
+ * Each source is read on its own, so a dropped JSON box does not wipe rows that
984
+ * parsed. The JSON box goes on last, so a builder can override a single key the
985
+ * key-value rows set without rewriting the rows as JSON. Answers `undefined`
986
+ * when neither field contributed anything, which keeps the key off the wire
987
+ * rather than sending an empty object.
988
+ */
989
+ function readProperties(entriesJson, text, invalid) {
990
+ return Effect.gen(function* () {
991
+ const fromRows = entriesJson ? yield* readKeyValueProperties(entriesJson, invalid) : void 0;
992
+ const fromJson = text?.trim() ? yield* readJsonProperties(text, invalid) : void 0;
993
+ const merged = {
994
+ ...asObject(fromRows),
995
+ ...asObject(fromJson)
996
+ };
997
+ return isEmptyObject(merged) ? void 0 : merged;
998
+ });
999
+ }
1000
+ // src/posthog/index.ts
1001
+ /**
1002
+ * The PostHog integration: its credentials, its two actions, and what each takes
1003
+ * and gives back.
594
1004
  *
595
1005
  * One file, because only the server imports it. The editor gets this plugin's
596
1006
  * metadata as JSON over `/api/extensions`, so nothing here reaches a browser
597
- * bundle and the system client below costs the browser nothing. The icon is the
598
- * exception, since a React component cannot be serialized: it stays in `ui.ts`,
599
- * which only the browser imports.
1007
+ * bundle. The icon is the exception, because a React component cannot be
1008
+ * serialized: it stays in `ui.ts`, which only the browser imports.
1009
+ *
1010
+ * Both actions post to the same endpoint. Capturing an event and identifying a
1011
+ * person are one call to PostHog with a different event name, and the split into
1012
+ * two actions is for the person configuring them, not for the wire.
1013
+ */
1014
+ const posthogCredentialFields = {
1015
+ POSTHOG_PROJECT_API_KEY: {
1016
+ label: "Project API Key",
1017
+ type: "password",
1018
+ placeholder: "phc_...",
1019
+ helpText: "Find your project API key in ",
1020
+ helpLink: {
1021
+ text: "PostHog project settings",
1022
+ url: "https://posthog.com/docs/api/capture"
1023
+ }
1024
+ },
1025
+ POSTHOG_HOST: {
1026
+ label: "API Host",
1027
+ type: "url",
1028
+ placeholder: "https://us.i.posthog.com",
1029
+ helpText: "https://us.i.posthog.com for US Cloud, https://eu.i.posthog.com for EU Cloud, or your own domain. Blank means US Cloud."
1030
+ }
1031
+ };
1032
+ /** The distinct id PostHog will not accept an event without. */
1033
+ const MISSING_DISTINCT_ID = "distinctId resolved to nothing. PostHog needs a distinct id to attach the event to a person.";
1034
+ /**
1035
+ * The Capture Event config, as the step reads it.
1036
+ *
1037
+ * Every field is a string because that is what a resolved config field is: the
1038
+ * editor writes text, and a template variable resolves to text. `optionalKey` for
1039
+ * a field a builder may leave blank, which reaches a step as an absent key.
1040
+ */
1041
+ const captureEventInput = Schema.Struct({
1042
+ eventName: Schema.String,
1043
+ distinctId: Schema.String,
1044
+ /** The key-value widget's rows, as JSON the step parses. */
1045
+ properties: Schema.optionalKey(Schema.String),
1046
+ /** JSON the workflow author typed, for a property that is not a string. */
1047
+ propertiesJson: Schema.optionalKey(Schema.String),
1048
+ timestamp: Schema.optionalKey(Schema.String),
1049
+ personProfile: Schema.optionalKey(Schema.String),
1050
+ testBehavior: Schema.optionalKey(Schema.String)
1051
+ });
1052
+ /** The Identify Person config. `$set` and `$set_once` are PostHog's own names. */
1053
+ const identifyPersonInput = Schema.Struct({
1054
+ distinctId: Schema.String,
1055
+ setProperties: Schema.optionalKey(Schema.String),
1056
+ setPropertiesJson: Schema.optionalKey(Schema.String),
1057
+ setOnceProperties: Schema.optionalKey(Schema.String),
1058
+ testBehavior: Schema.optionalKey(Schema.String)
1059
+ });
1060
+ /**
1061
+ * What a captured event leaves for the nodes downstream of it.
1062
+ *
1063
+ * PostHog answers nothing that identifies the event, so what is worth passing on
1064
+ * is what this step decided: the uuid it minted, which is what the event can be
1065
+ * found by, and the timestamp it was stamped with.
1066
+ *
1067
+ * `optionalKey(NullOr(...))` on the way out, which is the one spelling that
1068
+ * survives both a key the handler leaves out and a null it writes.
1069
+ */
1070
+ const captureEventOutput = Schema.Struct({
1071
+ eventName: Schema.String,
1072
+ distinctId: Schema.String.annotate({ title: "Person distinct ID" }),
1073
+ eventUuid: Schema.String,
1074
+ timestamp: Schema.String.annotate({ title: "Event timestamp" }),
1075
+ /** Absent on a real capture: this is why a test run made none. */
1076
+ reasonCode: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Why a test run did not capture" })))
1077
+ });
1078
+ const identifyPersonOutput = Schema.Struct({
1079
+ distinctId: Schema.String.annotate({ title: "Person distinct ID" }),
1080
+ eventUuid: Schema.String,
1081
+ timestamp: Schema.String.annotate({ title: "Event timestamp" }),
1082
+ reasonCode: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Why a test run did not identify" })))
1083
+ });
1084
+ function resolvePostHogTestBehavior(value) {
1085
+ return value === "send" ? "send" : "log_only";
1086
+ }
1087
+ const posthog = defineIntegration({
1088
+ type: "posthog",
1089
+ label: "PostHog",
1090
+ description: "Capture product analytics events and identify people",
1091
+ credentials: posthogCredentialFields,
1092
+ oauth: posthogOAuth,
1093
+ test: async () => (await import("./test-CTNffdZM.js")).testPostHog,
1094
+ actions: {
1095
+ "capture-event": {
1096
+ label: "Capture Event",
1097
+ description: "Send an event to PostHog",
1098
+ sideEffect: true,
1099
+ input: captureEventInput,
1100
+ output: captureEventOutput,
1101
+ configFields: [
1102
+ {
1103
+ key: "eventName",
1104
+ label: "Event Name",
1105
+ type: "template-input",
1106
+ placeholder: "user_signed_up",
1107
+ example: "user_signed_up",
1108
+ required: true
1109
+ },
1110
+ {
1111
+ key: "distinctId",
1112
+ label: "Distinct ID",
1113
+ type: "template-input",
1114
+ placeholder: "The person this event belongs to",
1115
+ example: "user_12345",
1116
+ required: true
1117
+ },
1118
+ {
1119
+ key: "properties",
1120
+ label: "Properties",
1121
+ type: "key-value"
1122
+ },
1123
+ {
1124
+ key: "personProfile",
1125
+ label: "Person Profile",
1126
+ type: "select",
1127
+ defaultValue: "identified",
1128
+ options: [{
1129
+ value: "identified",
1130
+ label: "Identified (creates a person)"
1131
+ }, {
1132
+ value: "anonymous",
1133
+ label: "Anonymous (no person profile)"
1134
+ }]
1135
+ },
1136
+ {
1137
+ key: "testBehavior",
1138
+ label: "Test Mode Behavior",
1139
+ type: "select",
1140
+ defaultValue: "log_only",
1141
+ options: [{
1142
+ value: "log_only",
1143
+ label: "Log only (do nothing)"
1144
+ }, {
1145
+ value: "send",
1146
+ label: "Capture a real event"
1147
+ }]
1148
+ },
1149
+ {
1150
+ type: "group",
1151
+ label: "Advanced",
1152
+ fields: [{
1153
+ key: "propertiesJson",
1154
+ label: "Properties JSON",
1155
+ type: "template-textarea",
1156
+ placeholder: "For a property that is not text: {\"plan\": \"pro\", \"seats\": 12, \"trial\": false}",
1157
+ rows: 4,
1158
+ example: "{\"seats\": 12}"
1159
+ }, {
1160
+ key: "timestamp",
1161
+ label: "Timestamp (ISO 8601)",
1162
+ type: "template-input",
1163
+ placeholder: "Blank means the moment the step runs",
1164
+ example: "2024-12-25T09:00:00Z"
1165
+ }]
1166
+ }
1167
+ ],
1168
+ handler: Effect.fn(function* (bag) {
1169
+ const { input } = bag;
1170
+ const testBehavior = resolvePostHogTestBehavior(input.testBehavior);
1171
+ if (bag.runMode === "test" && testBehavior === "log_only") return {
1172
+ eventName: input.eventName,
1173
+ distinctId: input.distinctId,
1174
+ eventUuid: "",
1175
+ timestamp: "",
1176
+ reasonCode: "test_mode_log_only"
1177
+ };
1178
+ const connection = yield* connectionFrom(yield* bag.credentials);
1179
+ const eventName = input.eventName.trim();
1180
+ const distinctId = input.distinctId.trim();
1181
+ if (!eventName) return yield* new StepFailure({ message: "eventName resolved to nothing. PostHog needs an event name." });
1182
+ if (!distinctId) return yield* new StepFailure({ message: MISSING_DISTINCT_ID });
1183
+ const identity = yield* bag.step.run("identity", eventIdentity);
1184
+ const timestamp = input.timestamp?.trim() || identity.timestamp;
1185
+ const authored = yield* readProperties(input.properties, input.propertiesJson, "drop");
1186
+ const properties = input.personProfile === "anonymous" ? {
1187
+ ...authored,
1188
+ $process_person_profile: false
1189
+ } : authored;
1190
+ yield* bag.step.run("capture", captureOrFail("Failed to capture event", connection, omitUndefined({
1191
+ event: eventName,
1192
+ distinct_id: distinctId,
1193
+ uuid: identity.uuid,
1194
+ timestamp,
1195
+ properties
1196
+ })));
1197
+ return {
1198
+ eventName,
1199
+ distinctId,
1200
+ eventUuid: identity.uuid,
1201
+ timestamp
1202
+ };
1203
+ })
1204
+ },
1205
+ "identify-person": {
1206
+ label: "Identify Person",
1207
+ description: "Set properties on a person in PostHog",
1208
+ sideEffect: true,
1209
+ input: identifyPersonInput,
1210
+ output: identifyPersonOutput,
1211
+ configFields: [
1212
+ {
1213
+ key: "distinctId",
1214
+ label: "Distinct ID",
1215
+ type: "template-input",
1216
+ placeholder: "The person to identify",
1217
+ example: "user_12345",
1218
+ required: true
1219
+ },
1220
+ {
1221
+ key: "setProperties",
1222
+ label: "Set Properties (overwrites)",
1223
+ type: "key-value"
1224
+ },
1225
+ {
1226
+ key: "setOnceProperties",
1227
+ label: "Set Once (keeps any existing value)",
1228
+ type: "key-value"
1229
+ },
1230
+ {
1231
+ key: "testBehavior",
1232
+ label: "Test Mode Behavior",
1233
+ type: "select",
1234
+ defaultValue: "log_only",
1235
+ options: [{
1236
+ value: "log_only",
1237
+ label: "Log only (do nothing)"
1238
+ }, {
1239
+ value: "send",
1240
+ label: "Identify for real"
1241
+ }]
1242
+ },
1243
+ {
1244
+ type: "group",
1245
+ label: "Advanced",
1246
+ fields: [{
1247
+ key: "setPropertiesJson",
1248
+ label: "Set Properties JSON",
1249
+ type: "template-textarea",
1250
+ placeholder: "For a property that is not text: {\"plan\": \"pro\", \"seats\": 12}",
1251
+ rows: 4,
1252
+ example: "{\"seats\": 12}"
1253
+ }]
1254
+ }
1255
+ ],
1256
+ handler: Effect.fn(function* (bag) {
1257
+ const { input } = bag;
1258
+ const testBehavior = resolvePostHogTestBehavior(input.testBehavior);
1259
+ if (bag.runMode === "test" && testBehavior === "log_only") return {
1260
+ distinctId: input.distinctId,
1261
+ eventUuid: "",
1262
+ timestamp: "",
1263
+ reasonCode: "test_mode_log_only"
1264
+ };
1265
+ const connection = yield* connectionFrom(yield* bag.credentials);
1266
+ const distinctId = input.distinctId.trim();
1267
+ if (!distinctId) return yield* new StepFailure({ message: MISSING_DISTINCT_ID });
1268
+ const set = yield* readProperties(input.setProperties, input.setPropertiesJson, "fail");
1269
+ const setOnce = yield* readProperties(input.setOnceProperties, void 0, "fail");
1270
+ if (!(set || setOnce)) return yield* new StepFailure({ message: "Identify Person needs at least one property to set. Add a row to Set Properties or Set Once." });
1271
+ const identity = yield* bag.step.run("identity", eventIdentity);
1272
+ yield* bag.step.run("identify", captureOrFail("Failed to identify person", connection, omitUndefined({
1273
+ event: "$identify",
1274
+ distinct_id: distinctId,
1275
+ uuid: identity.uuid,
1276
+ timestamp: identity.timestamp,
1277
+ $set: set,
1278
+ $set_once: setOnce
1279
+ })));
1280
+ return {
1281
+ distinctId,
1282
+ eventUuid: identity.uuid,
1283
+ timestamp: identity.timestamp
1284
+ };
1285
+ })
1286
+ }
1287
+ }
1288
+ });
1289
+ // src/resend/events.ts
1290
+ /**
1291
+ * The 19 Events Resend's webhook can raise, each a Workflow Graph Event on the
1292
+ * umbrella Inngest source `resend/webhook`.
1293
+ *
1294
+ * Identity is `resend/{type}` so the Lifecycle Node inspector lists one Event per thing
1295
+ * that happened. The listener filter is `source.when` on the envelope's `type`.
1296
+ * Payload is the Svix envelope `{ type, created_at, data }`. Extra keys pass
1297
+ * the intake gate; the structs below name the fields the editor offers.
1298
+ *
1299
+ * One rule decides every spelling here. A key is required where Resend's docs
1300
+ * and `resend-node`'s webhook types agree it is always sent, and
1301
+ * `Schema.optionalKey` where either source omits it or calls it conditional, as
1302
+ * the docs do for `template_id` with "(if applicable)". A field Resend documents
1303
+ * as an explicit null takes `NullOr` around whatever it holds. Where the sources
1304
+ * disagree the looser reading wins, because a refused payload never reaches
1305
+ * Resend: the POST was answered 200 at intake, so the refusal is visible only
1306
+ * to whoever reads the Inngest run history.
1307
+ *
1308
+ * The rule is worth following closely, since an optional key reaches the
1309
+ * condition picker as a nullable field, which is what offers a builder `is set`
1310
+ * and `is not set` on the path. Marking a field Resend always sends costs those
1311
+ * two operators their meaning.
1312
+ */
1313
+ const RESEND_WEBHOOK_SOURCE = "resend/webhook";
1314
+ const RESEND_WEBHOOK_EVENT_TYPES = [
1315
+ "email.sent",
1316
+ "email.delivered",
1317
+ "email.delivery_delayed",
1318
+ "email.bounced",
1319
+ "email.complained",
1320
+ "email.opened",
1321
+ "email.clicked",
1322
+ "email.failed",
1323
+ "email.scheduled",
1324
+ "email.suppressed",
1325
+ "email.received",
1326
+ "domain.created",
1327
+ "domain.updated",
1328
+ "domain.deleted",
1329
+ "contact.created",
1330
+ "contact.updated",
1331
+ "contact.deleted",
1332
+ "suppression.added",
1333
+ "suppression.removed"
1334
+ ];
1335
+ const createdAt = isoTimestampString("When Resend created this Event");
1336
+ /**
1337
+ * What every outbound `email.*` payload carries.
1338
+ *
1339
+ * Resend documents `broadcast_id` and `template_id` as "(if applicable)" and
1340
+ * types `tags` optional, so those three are the keys a run can arrive without.
1341
+ * The rest are on every documented example and non-optional in `resend-node`.
1342
+ */
1343
+ const emailFields = {
1344
+ email_id: Schema.String,
1345
+ created_at: isoTimestampString("When the email was created"),
1346
+ from: Schema.String.annotate({ title: "Sender" }),
1347
+ to: Schema.mutable(Schema.Array(Schema.String)).annotate({ title: "Recipients" }),
1348
+ subject: Schema.String,
1349
+ message_id: Schema.String.annotate({ title: "RFC Message-ID" }),
1350
+ broadcast_id: Schema.optionalKey(Schema.String),
1351
+ template_id: Schema.optionalKey(Schema.String),
1352
+ tags: Schema.optionalKey(Schema.Record(Schema.String, Schema.String).annotate({ title: "Email tags" }))
1353
+ };
1354
+ const emailData = Schema.Struct(emailFields);
1355
+ const emailClickedData = Schema.Struct({
1356
+ ...emailFields,
1357
+ click: Schema.Struct({
1358
+ ipAddress: Schema.String.annotate({ title: "Clicker IP address" }),
1359
+ link: Schema.String.annotate({ title: "Clicked URL" }),
1360
+ timestamp: isoTimestampString("When the link was clicked"),
1361
+ userAgent: Schema.String.annotate({ title: "Clicker user agent" })
1362
+ }).annotate({ title: "Click tracking details" })
1363
+ });
1364
+ const emailBouncedData = Schema.Struct({
1365
+ ...emailFields,
1366
+ bounce: Schema.Struct({
1367
+ message: Schema.String.annotate({ title: "Bounce message" }),
1368
+ subType: Schema.String.annotate({ title: "Bounce sub-type" }),
1369
+ type: Schema.String.annotate({ title: "Bounce type" }),
1370
+ diagnosticCode: Schema.optionalKey(Schema.mutable(Schema.Array(Schema.String)).annotate({ description: "Raw SMTP responses from the receiving server" }))
1371
+ }).annotate({ title: "Bounce details" })
1372
+ });
1373
+ const emailFailedData = Schema.Struct({
1374
+ ...emailFields,
1375
+ failed: Schema.Struct({ reason: Schema.String.annotate({ description: "Why sending failed" }) }).annotate({ title: "Failure details" })
1376
+ });
1377
+ const emailSuppressedData = Schema.Struct({
1378
+ ...emailFields,
1379
+ suppressed: Schema.Struct({
1380
+ message: Schema.String.annotate({ title: "Suppression message" }),
1381
+ type: Schema.String.annotate({ title: "Suppression type" })
1382
+ }).annotate({ title: "Suppression details" })
1383
+ });
1384
+ /**
1385
+ * An inbound email, which carries its own set of keys.
1386
+ *
1387
+ * `resend-node` gives `email.received` its own payload type carrying the
1388
+ * addressing fields and the attachment metadata, with no `broadcast_id`,
1389
+ * `template_id` or `tags`, so this names its fields rather than spreading
1390
+ * `emailFields`.
1391
+ *
1392
+ * The received docs page does list those three, which looks like the source
1393
+ * disagreement the header rule resolves in favour of the looser reading. It is
1394
+ * a different question: that page renders the parameter list every email page
1395
+ * shares, and its own JSON example carries none of the three. Whether a key is
1396
+ * required is what the looser reading settles. Whether a path is declared at
1397
+ * all follows the evidence about this payload, and the example and the SDK
1398
+ * agree against the shared list.
1399
+ *
1400
+ * The body, the headers and the attachment contents are outside the payload;
1401
+ * Resend's Received emails and Attachments APIs are where those are read.
1402
+ */
1403
+ const emailReceivedData = Schema.Struct({
1404
+ email_id: Schema.String,
1405
+ created_at: isoTimestampString("When the email was received"),
1406
+ from: Schema.String.annotate({
1407
+ title: "Sender",
1408
+ description: "The sender as a bare address with no display name."
1409
+ }),
1410
+ to: Schema.mutable(Schema.Array(Schema.String)).annotate({ title: "Recipients" }),
1411
+ cc: Schema.mutable(Schema.Array(Schema.String)).annotate({ title: "CC recipients" }),
1412
+ bcc: Schema.mutable(Schema.Array(Schema.String)).annotate({ title: "BCC recipients" }),
1413
+ received_for: Schema.mutable(Schema.Array(Schema.String)).annotate({
1414
+ title: "Received for",
1415
+ description: "Addresses the email was received for."
1416
+ }),
1417
+ message_id: Schema.String.annotate({ title: "RFC Message-ID" }),
1418
+ subject: Schema.String,
1419
+ attachments: Schema.mutable(Schema.Array(Schema.Struct({
1420
+ id: Schema.String.annotate({ title: "Attachment ID" }),
1421
+ filename: Schema.NullOr(Schema.String),
1422
+ content_type: Schema.String,
1423
+ content_disposition: Schema.NullOr(Schema.String),
1424
+ content_id: Schema.NullOr(Schema.String)
1425
+ }))).annotate({ title: "Attachment metadata" })
1426
+ });
1427
+ const domainData = Schema.Struct({
1428
+ id: Schema.String.annotate({ title: "Domain ID" }),
1429
+ name: Schema.String.annotate({ title: "Domain name" }),
1430
+ status: Schema.String.annotate({ title: "Verification status" }),
1431
+ created_at: isoTimestampString("When the domain was created"),
1432
+ region: Schema.String.annotate({ title: "AWS region" }),
1433
+ capabilities: Schema.optionalKey(Schema.Struct({
1434
+ sending: Schema.String.annotate({ title: "Sending capability" }),
1435
+ receiving: Schema.String.annotate({ title: "Receiving capability" })
1436
+ }).annotate({ title: "Domain capabilities" })),
1437
+ records: Schema.mutable(Schema.Array(Schema.Struct({
1438
+ record: Schema.String.annotate({ title: "Record purpose" }),
1439
+ name: Schema.String.annotate({ title: "DNS name" }),
1440
+ type: Schema.String.annotate({ title: "DNS type" }),
1441
+ value: Schema.String.annotate({ title: "DNS value" }),
1442
+ ttl: Schema.String,
1443
+ status: Schema.String.annotate({ title: "Record status" }),
1444
+ priority: Schema.optionalKey(Schema.Number.annotate({ title: "MX priority" }))
1445
+ }))).annotate({ title: "DNS records" })
1446
+ });
1447
+ const contactData = Schema.Struct({
1448
+ id: Schema.String.annotate({ title: "Contact ID" }),
1449
+ audience_id: Schema.optionalKey(Schema.String),
1450
+ segment_ids: Schema.optionalKey(Schema.mutable(Schema.Array(Schema.String))),
1451
+ created_at: isoTimestampString("When the contact was created"),
1452
+ updated_at: isoTimestampString("When the contact was last updated"),
1453
+ email: Schema.String.annotate({ title: "Contact email" }),
1454
+ first_name: Schema.optionalKey(Schema.NullOr(Schema.String)),
1455
+ last_name: Schema.optionalKey(Schema.NullOr(Schema.String)),
1456
+ unsubscribed: Schema.Boolean.annotate({ title: "Unsubscribed from all emails" })
1457
+ });
1458
+ const suppressionData = Schema.Struct({
1459
+ id: Schema.String.annotate({ title: "Suppression ID" }),
1460
+ email: Schema.String.annotate({ title: "Suppressed address" }),
1461
+ origin: Schema.Literals([
1462
+ "bounce",
1463
+ "complaint",
1464
+ "manual"
1465
+ ]).annotate({ description: "How the address was suppressed" }),
1466
+ source_id: Schema.NullOr(Schema.String).annotate({ description: "Email that triggered the suppression" }),
1467
+ created_at: isoTimestampString("When the suppression was created")
1468
+ });
1469
+ function resendEnvelope(data) {
1470
+ return Schema.Struct({
1471
+ type: Schema.String.annotate({ title: "Resend event type" }),
1472
+ created_at: createdAt,
1473
+ data
1474
+ });
1475
+ }
1476
+ function emailEvent(type, label, description, data = emailData) {
1477
+ return defineEvent({
1478
+ name: `resend/${type}`,
1479
+ label,
1480
+ description,
1481
+ schema: resendEnvelope(data),
1482
+ correlationPath: "data.email_id",
1483
+ source: {
1484
+ event: RESEND_WEBHOOK_SOURCE,
1485
+ when: {
1486
+ path: "type",
1487
+ equals: type
1488
+ }
1489
+ }
1490
+ });
1491
+ }
1492
+ function idEvent(type, label, description, data) {
1493
+ return defineEvent({
1494
+ name: `resend/${type}`,
1495
+ label,
1496
+ description,
1497
+ schema: resendEnvelope(data),
1498
+ correlationPath: "data.id",
1499
+ source: {
1500
+ event: RESEND_WEBHOOK_SOURCE,
1501
+ when: {
1502
+ path: "type",
1503
+ equals: type
1504
+ }
1505
+ }
1506
+ });
1507
+ }
1508
+ const resendEvents = [
1509
+ emailEvent("email.sent", "Email sent", "Resend accepted the email and queued it for delivery."),
1510
+ emailEvent("email.delivered", "Email delivered", "The recipient's mail server accepted the email."),
1511
+ emailEvent("email.delivery_delayed", "Email delivery delayed", "Delivery was delayed by a temporary issue at the receiving server."),
1512
+ emailEvent("email.bounced", "Email bounced", "The recipient's mail server permanently rejected the email.", emailBouncedData),
1513
+ emailEvent("email.complained", "Email complained", "The recipient marked the email as spam."),
1514
+ emailEvent("email.opened", "Email opened", "The recipient opened the email."),
1515
+ emailEvent("email.clicked", "Email clicked", "The recipient clicked a link in the email.", emailClickedData),
1516
+ emailEvent("email.failed", "Email failed", "Resend could not send the email.", emailFailedData),
1517
+ emailEvent("email.scheduled", "Email scheduled", "The email was scheduled for later sending."),
1518
+ emailEvent("email.suppressed", "Email suppressed", "Resend did not send the email because the address is suppressed.", emailSuppressedData),
1519
+ emailEvent("email.received", "Email received", "Resend received an inbound email. The body is not in the payload.", emailReceivedData),
1520
+ idEvent("domain.created", "Domain created", "A sending or receiving domain was created.", domainData),
1521
+ idEvent("domain.updated", "Domain updated", "A domain's records or status changed.", domainData),
1522
+ idEvent("domain.deleted", "Domain deleted", "A domain was deleted.", domainData),
1523
+ idEvent("contact.created", "Contact created", "A contact was created in an audience.", contactData),
1524
+ idEvent("contact.updated", "Contact updated", "A contact was updated.", contactData),
1525
+ idEvent("contact.deleted", "Contact deleted", "A contact was deleted.", contactData),
1526
+ idEvent("suppression.added", "Suppression added", "An address was added to the suppression list.", suppressionData),
1527
+ idEvent("suppression.removed", "Suppression removed", "An address was removed from the suppression list.", suppressionData)
1528
+ ];
1529
+ // src/resend/oauth.ts
1530
+ /**
1531
+ * Resend's public OAuth client.
1532
+ *
1533
+ * Resend identifies a metadata-document client by its document URL. The adapter
1534
+ * keeps that URL in every provider request and sends all token material as form
1535
+ * data so the server can apply the same rules to exchange, refresh, and revoke.
1536
+ */
1537
+ const RESEND_AUTHORIZE_URL = "https://api.resend.com/oauth/authorize";
1538
+ const RESEND_TOKEN_URL = "https://api.resend.com/oauth/token";
1539
+ const RESEND_REVOKE_URL = "https://api.resend.com/oauth/revoke";
1540
+ /**
1541
+ * Resend's whole scope vocabulary. There are two and nothing between them:
1542
+ * `emails:send` covers the send routes, `full_access` covers every other route,
1543
+ * which is why reading a template means asking for the account.
1544
+ *
1545
+ * The client registers both. The authorization then asks for neither by name,
1546
+ * because Resend's own consent page carries the Permission chooser and an
1547
+ * omitted `scope` requests the client's whole registered set. Naming one here
1548
+ * would only preselect it, and this app has no business deciding for an
1549
+ * operator looking at the page.
1550
+ */
1551
+ const RESEND_SEND_SCOPE = "emails:send";
1552
+ const RESEND_FULL_SCOPE = "full_access";
1553
+ /**
1554
+ * What Resend granted, worded as its consent page words it, so an operator
1555
+ * recognizes the connection's access as the thing they just approved.
1556
+ *
1557
+ * A scope this cannot name answers nothing rather than throwing. The label is
1558
+ * what a dialog draws; the tokens beside it are what the connection runs on, and
1559
+ * a refresh that threw here would turn a working grant into one an operator has
1560
+ * to reauthorize over a word.
1561
+ */
1562
+ function accessLabelFromScope(scope) {
1563
+ const granted = new Set(compact(scope.split(/\s+/u)));
1564
+ if (granted.has(RESEND_FULL_SCOPE)) return "Full access";
1565
+ if (granted.has(RESEND_SEND_SCOPE)) return "Sending access";
1566
+ }
1567
+ const resendOAuthTokenResponseSchema = Schema.Struct({
1568
+ access_token: Schema.String,
1569
+ token_type: Schema.Literal("Bearer"),
1570
+ expires_in: Schema.Finite.check(Schema.isGreaterThan(0)),
1571
+ refresh_token: Schema.String,
1572
+ scope: Schema.String
1573
+ });
1574
+ function tokenSet$1(response) {
1575
+ const accessLabel = accessLabelFromScope(response.scope);
1576
+ return {
1577
+ credentials: { RESEND_API_KEY: response.access_token },
1578
+ grantedAccessLabel: accessLabel,
1579
+ tokens: {
1580
+ accessToken: response.access_token,
1581
+ refreshToken: response.refresh_token,
1582
+ expiresAt: new Date(Date.now() + response.expires_in * 1e3).toISOString()
1583
+ }
1584
+ };
1585
+ }
1586
+ function requestResendToken(url, body, schema, secrets) {
1587
+ return requestCimdToken({
1588
+ system: "Resend OAuth",
1589
+ url,
1590
+ body,
1591
+ schema,
1592
+ secrets
1593
+ });
1594
+ }
1595
+ const resendOAuth = {
1596
+ label: "Resend",
1597
+ pkce: "S256",
1598
+ registerClient: (context) => ({
1599
+ clientId: context.metadataDocumentUrl,
1600
+ metadataDocument: {
1601
+ client_id: context.metadataDocumentUrl,
1602
+ client_name: "Workflow Graph",
1603
+ client_uri: context.publicUrl,
1604
+ redirect_uris: [context.callbackUrl],
1605
+ grant_types: ["authorization_code", "refresh_token"],
1606
+ response_types: ["code"],
1607
+ token_endpoint_auth_method: "none",
1608
+ scope: `${RESEND_SEND_SCOPE} ${RESEND_FULL_SCOPE}`
1609
+ }
1610
+ }),
1611
+ authorize: ({ client, redirectUri, state, codeChallenge }) => {
1612
+ const url = new URL(RESEND_AUTHORIZE_URL);
1613
+ url.searchParams.set("client_id", client.clientId);
1614
+ url.searchParams.set("response_type", "code");
1615
+ url.searchParams.set("redirect_uri", redirectUri);
1616
+ url.searchParams.set("state", state);
1617
+ url.searchParams.set("code_challenge", codeChallenge);
1618
+ url.searchParams.set("code_challenge_method", "S256");
1619
+ return url;
1620
+ },
1621
+ exchange: async ({ client, code, redirectUri, codeVerifier }) => {
1622
+ return tokenSet$1(await requestResendToken(RESEND_TOKEN_URL, new URLSearchParams({
1623
+ grant_type: "authorization_code",
1624
+ client_id: client.clientId,
1625
+ code,
1626
+ redirect_uri: redirectUri,
1627
+ code_verifier: codeVerifier
1628
+ }), resendOAuthTokenResponseSchema, [
1629
+ client.clientId,
1630
+ code,
1631
+ codeVerifier
1632
+ ]));
1633
+ },
1634
+ refresh: async ({ client, grant }) => {
1635
+ const refreshToken = currentRefreshToken("Resend", grant);
1636
+ return tokenSet$1(await requestResendToken(RESEND_TOKEN_URL, new URLSearchParams({
1637
+ grant_type: "refresh_token",
1638
+ client_id: client.clientId,
1639
+ refresh_token: refreshToken
1640
+ }), resendOAuthTokenResponseSchema, [
1641
+ client.clientId,
1642
+ refreshToken,
1643
+ grant.tokens.accessToken
1644
+ ]));
1645
+ },
1646
+ revoke: async ({ client, grant }) => {
1647
+ const refreshToken = currentRefreshToken("Resend", grant);
1648
+ await requestResendToken(RESEND_REVOKE_URL, new URLSearchParams({
1649
+ client_id: client.clientId,
1650
+ token: refreshToken,
1651
+ token_type_hint: "refresh_token"
1652
+ }), emptyOAuthRevokeResponseSchema, [
1653
+ client.clientId,
1654
+ refreshToken,
1655
+ grant.tokens.accessToken
1656
+ ]);
1657
+ }
1658
+ };
1659
+ // src/resend/webhook.ts
1660
+ /**
1661
+ * Resend webhook intake: Svix verify on the raw body, then map a known
1662
+ * envelope type onto the umbrella `resend/webhook` send.
1663
+ *
1664
+ * The API key cannot verify a Svix signature. A Connection without
1665
+ * `RESEND_WEBHOOK_SECRET` is send-only; a POST to it is 401. Resend has no
1666
+ * URL-challenge handshake. An unknown `type` is ignored (200, no send).
1667
+ */
1668
+ const knownTypes = new Set(RESEND_WEBHOOK_EVENT_TYPES);
1669
+ function isResendWebhookEventType(value) {
1670
+ return knownTypes.has(value);
1671
+ }
1672
+ const resendWebhook = {
1673
+ source: RESEND_WEBHOOK_SOURCE,
1674
+ helpText: "Paste this URL into Resend.",
1675
+ secret: "RESEND_WEBHOOK_SECRET",
1676
+ verify: ({ rawBody, headers, credentials }) => {
1677
+ const secret = credentials.RESEND_WEBHOOK_SECRET;
1678
+ if (!secret) return Effect.fail(new SignatureRejected({ error: "This Connection has no webhook signing secret." }));
1679
+ return Effect.try({
1680
+ try: () => {
1681
+ new Webhook(secret).verify(rawBody, {
1682
+ "svix-id": headers.get("svix-id") ?? "",
1683
+ "svix-timestamp": headers.get("svix-timestamp") ?? "",
1684
+ "svix-signature": headers.get("svix-signature") ?? ""
1685
+ });
1686
+ },
1687
+ catch: () => new SignatureRejected({ error: "The webhook signature did not match this Connection's secret." })
1688
+ });
1689
+ },
1690
+ receive: (body, headers) => {
1691
+ const type = body.type;
1692
+ if (typeof type !== "string" || !isResendWebhookEventType(type)) return;
1693
+ return {
1694
+ data: body,
1695
+ id: headers.get("svix-id") || void 0
1696
+ };
1697
+ }
1698
+ };
1699
+ // src/resend/index.ts
1700
+ /**
1701
+ * The Resend integration: its credentials, its actions, the Events a
1702
+ * webhook raises, and the webhook that produces them.
1703
+ *
1704
+ * The `defineIntegration` call stays in this file. Event schemas and Svix
1705
+ * verify live in sibling modules, because they are large enough on their own.
1706
+ * The editor gets this plugin's metadata as JSON over `/api/extensions`, so
1707
+ * nothing here reaches a browser bundle. The icon is the exception, because a
1708
+ * React component cannot be serialized: it stays in `ui.ts`.
600
1709
  */
601
1710
  const resendCredentialFields = {
1711
+ RESEND_WEBHOOK_SECRET: {
1712
+ label: "Webhook Signing Secret",
1713
+ type: "password",
1714
+ placeholder: "whsec_...",
1715
+ helpText: "From the webhook details page in Resend. Optional on a send-only Connection; a POST without it is refused.",
1716
+ helpLink: {
1717
+ text: "resend.com/webhooks",
1718
+ url: "https://resend.com/docs/webhooks/event-types"
1719
+ }
1720
+ },
602
1721
  RESEND_API_KEY: {
603
1722
  label: "API Key",
604
1723
  type: "password",
@@ -620,11 +1739,13 @@ const TEST_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
620
1739
  /**
621
1740
  * The Send Email config, as the step reads it.
622
1741
  *
623
- * Every field is a string because that is what a resolved config field is: the
624
- * editor writes text, and a template variable resolves to text. Which of them a
1742
+ * Every field a builder types into is a string because that is what a resolved
1743
+ * config field is: the editor writes text, and a template variable resolves to
1744
+ * text. `emailContentMode` and `testBehavior` are the exception, because a
1745
+ * `select` writes one of its own option values and nothing else. Which fields a
625
1746
  * builder has to fill in is stated in `configFields`; what this schema says is
626
- * what the step may read. `optionalKey` for a field left blank, which reaches a
627
- * step as an absent key.
1747
+ * what the step may read.
1748
+ * `optionalKey` for a field left blank, which reaches a step as an absent key.
628
1749
  */
629
1750
  const sendEmailInput = Schema.Struct({
630
1751
  emailTo: Schema.String,
@@ -632,7 +1753,11 @@ const sendEmailInput = Schema.Struct({
632
1753
  emailFrom: Schema.optionalKey(Schema.String),
633
1754
  emailBody: Schema.optionalKey(Schema.String),
634
1755
  emailHtml: Schema.optionalKey(Schema.String),
635
- emailContentMode: Schema.optionalKey(Schema.String),
1756
+ emailContentMode: Schema.optionalKey(Schema.Literals([
1757
+ "text",
1758
+ "html",
1759
+ "template"
1760
+ ])),
636
1761
  emailTemplateId: Schema.optionalKey(Schema.String),
637
1762
  /** JSON the workflow author typed, parsed by the step. */
638
1763
  emailTemplateVariables: Schema.optionalKey(Schema.String),
@@ -643,326 +1768,554 @@ const sendEmailInput = Schema.Struct({
643
1768
  emailTopicId: Schema.optionalKey(Schema.String),
644
1769
  /** JSON the workflow author typed, parsed by the step. */
645
1770
  emailTags: Schema.optionalKey(Schema.String),
646
- testBehavior: Schema.optionalKey(Schema.String),
1771
+ testBehavior: Schema.optionalKey(Schema.Literals(["log_only", "send_to_test_email"])),
647
1772
  testEmailTo: Schema.optionalKey(Schema.String)
648
1773
  });
649
1774
  /**
650
- * What a sent email leaves for the nodes downstream of it.
651
- *
652
- * `optionalKey(NullOr(...))` on the way out, which is the one spelling that survives
653
- * both a key the handler leaves out and a null it writes where the system sent
654
- * nothing.
1775
+ * What a sent email leaves for the nodes downstream of it.
1776
+ *
1777
+ * `optionalKey(NullOr(...))` on the way out, which is the one spelling that survives
1778
+ * both a key the handler leaves out and a null it writes where the system sent
1779
+ * nothing.
1780
+ */
1781
+ const sendEmailOutput = Schema.Struct({
1782
+ id: Schema.String.annotate({ title: "Email ID" }),
1783
+ /** Absent on a real send: this is why a test run did not make one. */
1784
+ reasonCode: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Why a test run did not send" }))),
1785
+ /**
1786
+ * The tags this send carried, in the shape Resend's own webhooks echo them:
1787
+ * a key per tag name. The wire body sends the list Resend's send API takes,
1788
+ * so the boundary translates and `tags.order_id` then means the same thing
1789
+ * here and on a `resend/email.delivered` payload. A repeated tag name folds
1790
+ * to one key, which is what the webhook does with it too.
1791
+ */
1792
+ tags: Schema.optionalKey(Schema.Record(Schema.String, Schema.String).annotate({ title: "Email tags" }))
1793
+ });
1794
+ const findEmailInput = Schema.Struct({ emailId: Schema.String });
1795
+ const findEmailOutput = Schema.Struct({
1796
+ id: Schema.String.annotate({ title: "Email ID" }),
1797
+ messageId: Schema.String.annotate({ title: "Provider message ID" }),
1798
+ from: Schema.String.annotate({ title: "Sender" }),
1799
+ to: Schema.Array(Schema.String).annotate({ title: "Recipients" }),
1800
+ cc: Schema.NullOr(Schema.Array(Schema.String).annotate({ title: "CC recipients" })),
1801
+ bcc: Schema.NullOr(Schema.Array(Schema.String).annotate({ title: "BCC recipients" })),
1802
+ replyTo: Schema.NullOr(Schema.Array(Schema.String).annotate({ title: "Reply-to addresses" })),
1803
+ subject: Schema.String.annotate({ title: "Email subject" }),
1804
+ html: Schema.NullOr(Schema.String.annotate({ title: "HTML body" })),
1805
+ text: Schema.NullOr(Schema.String.annotate({ title: "Plain-text body" })),
1806
+ createdAt: isoTimestampString("When the email was created"),
1807
+ lastEvent: Schema.String.annotate({ title: "Latest email event" }),
1808
+ scheduledAt: Schema.NullOr(isoTimestampString("When the email was scheduled to send")),
1809
+ tags: Schema.optionalKey(Schema.Record(Schema.String, Schema.String).annotate({ title: "Email tags" }))
1810
+ });
1811
+ function isValidTestEmailAddress(value) {
1812
+ return TEST_EMAIL_PATTERN.test(value);
1813
+ }
1814
+ const emailTagsSchema = Schema.mutable(Schema.Array(Schema.Struct({
1815
+ name: Schema.String,
1816
+ value: Schema.String
1817
+ })));
1818
+ const templateVariablesSchema = Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Finite]));
1819
+ const decodeEmailTags = Schema.decodeUnknownResult(emailTagsSchema, { errors: "all" });
1820
+ const decodeTemplateVariables = Schema.decodeUnknownResult(templateVariablesSchema, { errors: "all" });
1821
+ /**
1822
+ * The value a JSON config box holds, or the failure naming a box whose text does
1823
+ * not describe what Resend accepts.
1824
+ *
1825
+ * Each decode asks for every issue rather than the first, so one failure accounts
1826
+ * for the whole string the author typed. The message names the field as the form
1827
+ * labels it and the shape it wanted, never the value it rejected.
1828
+ */
1829
+ function readAuthoredJson(text, decode, field) {
1830
+ return Effect.gen(function* () {
1831
+ const result = decode(yield* Effect.try({
1832
+ try: () => JSON.parse(text),
1833
+ catch: () => new StepFailure({ message: `${field.label} is not valid JSON.` })
1834
+ }));
1835
+ if (Result.isFailure(result)) return yield* new StepFailure({ message: `${field.label} must be ${field.shape}.` });
1836
+ return result.success;
1837
+ });
1838
+ }
1839
+ function readTags(tagsJson) {
1840
+ return readAuthoredJson(tagsJson, decodeEmailTags, {
1841
+ label: "Tags",
1842
+ shape: "a list of name and value entries"
1843
+ });
1844
+ }
1845
+ function readTemplateVariables(templateVariables) {
1846
+ return readAuthoredJson(templateVariables, decodeTemplateVariables, {
1847
+ label: "Template Variables",
1848
+ shape: "a JSON object of names to strings or numbers"
1849
+ });
1850
+ }
1851
+ /**
1852
+ * Email tags keyed by name, or nothing where Resend returned no tags.
1853
+ *
1854
+ * Resend's email API uses a list while its webhooks use a record, so this fold
1855
+ * gives both actions and Events the same reference shape. A repeated name keeps
1856
+ * the last row, matching what Resend does with one.
1857
+ */
1858
+ function tagsByName(tags) {
1859
+ return tags?.length ? Object.fromEntries(tags.map((tag) => [tag.name, tag.value])) : void 0;
1860
+ }
1861
+ /**
1862
+ * The body fields the chosen content mode contributes, or the failure naming the
1863
+ * box a builder still has to fill in.
1864
+ *
1865
+ * A template request carries neither html nor text, which Resend rejects, so each
1866
+ * mode adds its own fields and nothing else.
1867
+ */
1868
+ function emailContent(input) {
1869
+ return Effect.gen(function* () {
1870
+ switch (input.emailContentMode ?? "text") {
1871
+ case "template": {
1872
+ if (!input.emailTemplateId) return yield* new StepFailure({ message: "Content Mode is Template, so a Template must be chosen." });
1873
+ const variables = input.emailTemplateVariables ? yield* readTemplateVariables(input.emailTemplateVariables) : void 0;
1874
+ return { template: omitUndefined({
1875
+ id: input.emailTemplateId,
1876
+ variables
1877
+ }) };
1878
+ }
1879
+ case "html":
1880
+ if (!input.emailHtml) return yield* new StepFailure({ message: "Content Mode is HTML, so HTML Body must be filled in." });
1881
+ return omitUndefined({
1882
+ html: input.emailHtml,
1883
+ text: input.emailBody
1884
+ });
1885
+ default:
1886
+ if (!input.emailBody) return yield* new StepFailure({ message: "Content Mode is Text, so Text Body must be filled in." });
1887
+ return omitUndefined({
1888
+ text: input.emailBody,
1889
+ html: input.emailHtml
1890
+ });
1891
+ }
1892
+ });
1893
+ }
1894
+ /** The body Resend is sent. */
1895
+ const buildEmailPayload = Effect.fn(function* (input, senderEmail, recipients, tags) {
1896
+ const content = yield* emailContent(input);
1897
+ return omitUndefined({
1898
+ from: senderEmail,
1899
+ to: recipients.to,
1900
+ subject: input.emailSubject,
1901
+ cc: recipients.cc,
1902
+ bcc: recipients.bcc,
1903
+ reply_to: input.emailReplyTo,
1904
+ scheduled_at: input.emailScheduledAt,
1905
+ topic_id: input.emailTopicId,
1906
+ tags,
1907
+ ...content
1908
+ });
1909
+ });
1910
+ const resend = defineIntegration({
1911
+ type: "resend",
1912
+ label: "Resend",
1913
+ description: "Send transactional emails",
1914
+ credentials: resendCredentialFields,
1915
+ oauth: resendOAuth,
1916
+ events: resendEvents,
1917
+ webhook: resendWebhook,
1918
+ test: async () => (await import("./test-B5WBOt4r.js")).testResend,
1919
+ configOptions: {
1920
+ templates: {
1921
+ answers: "options",
1922
+ load: async () => (await import("./config-options-BgSb_XqZ.js")).resendTemplateOptions
1923
+ },
1924
+ "template-variables": {
1925
+ answers: "fields",
1926
+ load: async () => (await import("./config-options-BgSb_XqZ.js")).resendTemplateVariableFields
1927
+ }
1928
+ },
1929
+ actions: {
1930
+ "send-email": {
1931
+ label: "Send Email",
1932
+ description: "Send an email via Resend",
1933
+ sideEffect: true,
1934
+ input: sendEmailInput,
1935
+ output: sendEmailOutput,
1936
+ configFields: [
1937
+ {
1938
+ key: "emailFrom",
1939
+ label: "From (Sender)",
1940
+ type: "template-input",
1941
+ placeholder: "Your Name <noreply@example.com>",
1942
+ connectionDefaultKey: "RESEND_FROM_EMAIL",
1943
+ example: "Support <support@example.com>"
1944
+ },
1945
+ {
1946
+ key: "emailTo",
1947
+ label: "To",
1948
+ type: "template-input",
1949
+ placeholder: "recipient@example.com",
1950
+ example: "user@example.com",
1951
+ required: true
1952
+ },
1953
+ {
1954
+ key: "testBehavior",
1955
+ label: "Test Mode Behavior",
1956
+ type: "select",
1957
+ defaultValue: "log_only",
1958
+ options: [{
1959
+ value: "log_only",
1960
+ label: "Log only (do nothing)"
1961
+ }, {
1962
+ value: "send_to_test_email",
1963
+ label: "Send to test email"
1964
+ }]
1965
+ },
1966
+ {
1967
+ key: "testEmailTo",
1968
+ label: "Test Email Address",
1969
+ type: "text",
1970
+ placeholder: "test@example.com",
1971
+ literal: true,
1972
+ showWhen: {
1973
+ field: "testBehavior",
1974
+ equals: "send_to_test_email"
1975
+ }
1976
+ },
1977
+ {
1978
+ key: "emailSubject",
1979
+ label: "Subject",
1980
+ type: "template-input",
1981
+ placeholder: "Subject or {{NodeName.title}}",
1982
+ example: "Hello from my workflow",
1983
+ required: true
1984
+ },
1985
+ {
1986
+ key: "emailContentMode",
1987
+ label: "Content Mode",
1988
+ type: "select",
1989
+ defaultValue: "text",
1990
+ options: [
1991
+ {
1992
+ value: "text",
1993
+ label: "Text"
1994
+ },
1995
+ {
1996
+ value: "html",
1997
+ label: "HTML"
1998
+ },
1999
+ {
2000
+ value: "template",
2001
+ label: "Template"
2002
+ }
2003
+ ]
2004
+ },
2005
+ {
2006
+ key: "emailBody",
2007
+ label: "Text Body",
2008
+ type: "template-textarea",
2009
+ placeholder: "Email content or {{NodeName.description}}",
2010
+ rows: 5,
2011
+ example: "This is the email body content.",
2012
+ required: true,
2013
+ showWhen: {
2014
+ field: "emailContentMode",
2015
+ equals: "text"
2016
+ }
2017
+ },
2018
+ {
2019
+ key: "emailHtml",
2020
+ label: "HTML Body",
2021
+ type: "template-textarea",
2022
+ placeholder: "<p>Hello {{NodeName.name}}</p>",
2023
+ rows: 8,
2024
+ showWhen: {
2025
+ field: "emailContentMode",
2026
+ equals: "html"
2027
+ }
2028
+ },
2029
+ {
2030
+ key: "emailTemplateId",
2031
+ label: "Template",
2032
+ type: "provider-select",
2033
+ placeholder: "Choose a template",
2034
+ optionsSource: { provider: "templates" },
2035
+ showWhen: {
2036
+ field: "emailContentMode",
2037
+ equals: "template"
2038
+ }
2039
+ },
2040
+ {
2041
+ key: "emailTemplateVariables",
2042
+ label: "Template Variables",
2043
+ type: "provider-fields",
2044
+ rows: 6,
2045
+ placeholder: "{\"FIRST_NAME\":\"Alice\",\"APPOINTMENT_AT\":\"2026-03-10\"}",
2046
+ optionsSource: {
2047
+ provider: "template-variables",
2048
+ parameters: ["emailTemplateId"]
2049
+ },
2050
+ showWhen: {
2051
+ field: "emailContentMode",
2052
+ equals: "template"
2053
+ }
2054
+ },
2055
+ {
2056
+ type: "group",
2057
+ label: "Additional Recipients",
2058
+ fields: [
2059
+ {
2060
+ key: "emailCc",
2061
+ label: "CC",
2062
+ type: "template-input",
2063
+ placeholder: "cc@example.com",
2064
+ example: "manager@example.com"
2065
+ },
2066
+ {
2067
+ key: "emailBcc",
2068
+ label: "BCC",
2069
+ type: "template-input",
2070
+ placeholder: "bcc@example.com",
2071
+ example: "archive@example.com"
2072
+ },
2073
+ {
2074
+ key: "emailReplyTo",
2075
+ label: "Reply-To",
2076
+ type: "template-input",
2077
+ placeholder: "reply@example.com",
2078
+ example: "support@example.com"
2079
+ }
2080
+ ]
2081
+ },
2082
+ {
2083
+ type: "group",
2084
+ label: "Scheduling",
2085
+ fields: [{
2086
+ key: "emailScheduledAt",
2087
+ label: "Schedule At (ISO 8601)",
2088
+ type: "template-input",
2089
+ placeholder: "2024-12-25T09:00:00Z",
2090
+ example: "2024-12-25T09:00:00Z"
2091
+ }, {
2092
+ key: "emailTopicId",
2093
+ label: "Topic ID",
2094
+ type: "template-input",
2095
+ placeholder: "topic_abc123",
2096
+ example: "topic_abc123"
2097
+ }]
2098
+ },
2099
+ {
2100
+ type: "group",
2101
+ label: "Tags",
2102
+ fields: [{
2103
+ key: "emailTags",
2104
+ label: "",
2105
+ type: "key-value",
2106
+ fillsRecords: ["tags", "data.tags"]
2107
+ }]
2108
+ }
2109
+ ],
2110
+ handler: Effect.fn(function* (bag) {
2111
+ const { input } = bag;
2112
+ const idempotencyKey = bag.executionId;
2113
+ const syntheticIdSuffix = idempotencyKey ?? "no_execution";
2114
+ const testBehavior = input.testBehavior ?? "log_only";
2115
+ const tags = input.emailTags ? yield* readTags(input.emailTags) : void 0;
2116
+ const tagOutput = omitUndefined({ tags: tagsByName(tags) });
2117
+ if (bag.runMode === "test" && testBehavior === "log_only") return {
2118
+ ...tagOutput,
2119
+ id: `resend:test-log-only:${syntheticIdSuffix}`,
2120
+ reasonCode: "test_mode_log_only"
2121
+ };
2122
+ const testRecipient = input.testEmailTo?.trim() ?? "";
2123
+ const routeToTestRecipient = bag.runMode === "test" && testBehavior === "send_to_test_email";
2124
+ if (routeToTestRecipient && testRecipient.length === 0) return {
2125
+ ...tagOutput,
2126
+ id: `resend:test-log-fallback:${syntheticIdSuffix}`,
2127
+ reasonCode: "test_mode_log_fallback_missing_test_email"
2128
+ };
2129
+ if (routeToTestRecipient && !isValidTestEmailAddress(testRecipient)) return {
2130
+ ...tagOutput,
2131
+ id: `resend:test-log-fallback:${syntheticIdSuffix}`,
2132
+ reasonCode: "test_mode_log_fallback_invalid_test_email"
2133
+ };
2134
+ const credentials = yield* bag.credentials;
2135
+ const apiKey = credentials.RESEND_API_KEY;
2136
+ if (!apiKey) return yield* new StepFailure({ message: "RESEND_API_KEY is not configured. Please add it in Project Integrations." });
2137
+ const senderEmail = input.emailFrom || credentials.RESEND_FROM_EMAIL;
2138
+ if (!senderEmail) return yield* new StepFailure({ message: "No sender is configured. Please add it in the action or in Project Integrations." });
2139
+ const payload = yield* buildEmailPayload(input, senderEmail, routeToTestRecipient ? { to: testRecipient } : {
2140
+ to: input.emailTo,
2141
+ cc: input.emailCc,
2142
+ bcc: input.emailBcc
2143
+ }, tags);
2144
+ const sent = yield* bag.step.run("send", sendResendEmail(apiKey, payload, idempotencyKey).pipe(Effect.mapError((error) => new StepFailure({ message: `Failed to send email: ${describeResendFailure(error)}` }))));
2145
+ return {
2146
+ ...tagOutput,
2147
+ id: sent.id
2148
+ };
2149
+ })
2150
+ },
2151
+ "find-email": {
2152
+ label: "Find Email",
2153
+ description: "Retrieve a sent email by ID from Resend",
2154
+ input: findEmailInput,
2155
+ output: findEmailOutput,
2156
+ configFields: [{
2157
+ key: "emailId",
2158
+ label: "Email ID",
2159
+ type: "template-input",
2160
+ placeholder: "Email ID or {{NodeName.id}}",
2161
+ example: "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
2162
+ required: true
2163
+ }],
2164
+ handler: Effect.fn(function* (bag) {
2165
+ const emailId = bag.input.emailId.trim();
2166
+ if (!emailId) return yield* new StepFailure({ message: "Email ID is required." });
2167
+ const apiKey = (yield* bag.credentials).RESEND_API_KEY;
2168
+ if (!apiKey) return yield* new StepFailure({ message: "RESEND_API_KEY is not configured. Please add it in Project Integrations." });
2169
+ return yield* bag.step.run("find-email", getResendEmail(apiKey, emailId).pipe(Effect.mapBoth({
2170
+ onFailure: (error) => new StepFailure({ message: `Failed to find email: ${describeResendFailure(error)}` }),
2171
+ onSuccess: (email) => ({
2172
+ id: email.id,
2173
+ messageId: email.message_id,
2174
+ from: email.from,
2175
+ to: email.to,
2176
+ cc: email.cc,
2177
+ bcc: email.bcc,
2178
+ replyTo: email.reply_to,
2179
+ subject: email.subject,
2180
+ html: email.html,
2181
+ text: email.text,
2182
+ createdAt: email.created_at.toISOString(),
2183
+ lastEvent: email.last_event,
2184
+ scheduledAt: email.scheduled_at?.toISOString() ?? null,
2185
+ ...omitUndefined({ tags: tagsByName(email.tags) })
2186
+ })
2187
+ })));
2188
+ })
2189
+ }
2190
+ }
2191
+ });
2192
+ // src/slack/oauth.ts
2193
+ const SLACK_OAUTH_URL = "https://slack.com/api/oauth.v2.access";
2194
+ const SLACK_UNINSTALL_URL = "https://slack.com/api/apps.uninstall";
2195
+ const slackEnvelopeSchema = Schema.Struct({
2196
+ ok: Schema.Boolean,
2197
+ error: Schema.optionalKey(Schema.String)
2198
+ });
2199
+ const slackOAuthResponseSchema = Schema.Struct({
2200
+ ok: Schema.Boolean,
2201
+ access_token: Schema.String,
2202
+ expires_in: Schema.optionalKey(Schema.Finite),
2203
+ refresh_token: Schema.optionalKey(Schema.String),
2204
+ team: Schema.optionalKey(Schema.NullOr(Schema.Struct({
2205
+ id: Schema.optionalKey(Schema.String),
2206
+ name: Schema.optionalKey(Schema.String)
2207
+ })))
2208
+ });
2209
+ const slackOkResponseSchema = Schema.Struct({ ok: Schema.Boolean });
2210
+ /**
2211
+ * Keep provider failures useful while keeping authorization material out of
2212
+ * messages. Slack's documented error values are lowercase identifier slugs.
655
2213
  */
656
- const sendEmailOutput = Schema.Struct({
657
- id: Schema.String.annotate({ description: "Email ID" }),
658
- /** Absent on a real send: this is why a test run did not make one. */
659
- reasonCode: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Why a test run did not send" })))
660
- });
661
- function resolveResendTestBehavior(value) {
662
- return value === "send_to_test_email" ? "send_to_test_email" : "log_only";
663
- }
664
- function isValidTestEmailAddress(value) {
665
- return TEST_EMAIL_PATTERN.test(value);
2214
+ function readSafeSlackError(payload, sensitiveValues) {
2215
+ const error = parsePayload(payload, slackEnvelopeSchema)?.error;
2216
+ if (error === void 0) return;
2217
+ const errorCode = error.match(/^[a-z0-9_]{1,100}(?=$|:)/)?.[0];
2218
+ if (errorCode === void 0 || sensitiveValues.some((value) => value.length > 0 && errorCode === value)) return;
2219
+ return errorCode;
666
2220
  }
667
- const emailTagsSchema = Schema.mutable(Schema.Array(Schema.Struct({
668
- name: Schema.String,
669
- value: Schema.String
670
- })));
671
- const templateVariablesSchema = Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Finite]));
672
- const decodeEmailTags = Schema.decodeUnknownResult(emailTagsSchema, { errors: "all" });
673
- const decodeTemplateVariables = Schema.decodeUnknownResult(templateVariablesSchema, { errors: "all" });
674
- function parseTags(tagsJson) {
675
- let parsed;
676
- try {
677
- parsed = JSON.parse(tagsJson);
678
- } catch (error) {
679
- console.error("[Resend] Failed to parse tags JSON:", error);
680
- return;
681
- }
682
- const result = decodeEmailTags(parsed);
683
- if (Result.isFailure(result)) {
684
- console.error("[Resend] Tags JSON must be a list of { name, value } entries:", result.failure.message);
685
- return;
2221
+ function describeOAuthFailure(error, sensitiveValues) {
2222
+ if (error._tag === "ExternalUnreachable") return `Slack OAuth request failed: ${redactSensitive(error.message, sensitiveValues)}`;
2223
+ if (error._tag === "ExternalRejected") {
2224
+ const slackError = readSafeSlackError(error.payload, sensitiveValues);
2225
+ return slackError ? `Slack OAuth request rejected: ${slackError}` : `Slack OAuth request failed: HTTP ${error.status}`;
686
2226
  }
687
- return result.success;
2227
+ return `Slack OAuth request failed: HTTP ${error.status}`;
688
2228
  }
689
- function parseTemplateVariables(templateVariables) {
690
- if (!templateVariables) return;
691
- let parsed;
692
- try {
693
- parsed = JSON.parse(templateVariables);
694
- } catch (error) {
695
- console.error("[Resend] Failed to parse template variables JSON:", error);
696
- return;
697
- }
698
- const result = decodeTemplateVariables(parsed);
699
- if (Result.isFailure(result)) {
700
- console.error("[Resend] Template variables JSON must map names to strings or numbers:", result.failure.message);
701
- return;
702
- }
703
- return result.success;
2229
+ function redactSensitive(message, sensitiveValues) {
2230
+ return sensitiveValues.reduce((safeMessage, value) => value.length === 0 ? safeMessage : safeMessage.replaceAll(value, "[redacted]"), message);
704
2231
  }
705
- /**
706
- * The body Resend is sent, or the failure that says why one could not be built.
707
- *
708
- * The three content modes differ only in which fields they add to a common set,
709
- * and each names the field it cannot do without, so the message tells an author
710
- * which box to fill in.
711
- */
712
- function buildEmailPayload(input, senderEmail, recipients) {
713
- const basePayload = {
714
- from: senderEmail,
715
- to: recipients.to,
716
- subject: input.emailSubject,
717
- ...omitBy({
718
- cc: recipients.cc,
719
- bcc: recipients.bcc,
720
- reply_to: input.emailReplyTo,
721
- scheduled_at: input.emailScheduledAt,
722
- topic_id: input.emailTopicId,
723
- tags: input.emailTags ? parseTags(input.emailTags) : void 0
724
- }, isNil)
725
- };
726
- const contentMode = input.emailContentMode || "text";
727
- if (contentMode === "template") return input.emailTemplateId ? Effect.succeed({
728
- ...basePayload,
729
- template: {
730
- id: input.emailTemplateId,
731
- ...omitBy({ variables: parseTemplateVariables(input.emailTemplateVariables) }, isNil)
2232
+ function basicAuthorization(client) {
2233
+ return `Basic ${Base64.encode(`${client.clientId}:${client.clientSecret ?? ""}`)}`;
2234
+ }
2235
+ async function requestSlackOAuth(client, url, body, schema, options = {}) {
2236
+ const result = await callExternalAsync(callExternal({
2237
+ system: "Slack OAuth",
2238
+ url,
2239
+ method: "POST",
2240
+ headers: options.basicAuth === false ? {} : { authorization: basicAuthorization(client) },
2241
+ body: {
2242
+ kind: "form",
2243
+ value: body
2244
+ },
2245
+ schema,
2246
+ refusedInBody: (payload) => parsePayload(payload, slackEnvelopeSchema)?.ok !== true
2247
+ }), (error) => error);
2248
+ if (!result.ok) throw new Error(describeOAuthFailure(result.failure, [client.clientSecret ?? "", ...body.values()]));
2249
+ return result.data;
2250
+ }
2251
+ function tokenSet(response, options) {
2252
+ const hasExpiry = response.expires_in !== void 0;
2253
+ const hasRefreshToken = response.refresh_token !== void 0;
2254
+ if (options.requireRefreshToken && !hasRefreshToken) throw new Error("Slack OAuth refresh response must include a replacement refresh_token.");
2255
+ if (hasExpiry !== hasRefreshToken) throw new Error("Slack OAuth token response must include both expires_in and refresh_token for token rotation.");
2256
+ if (response.expires_in !== void 0 && response.expires_in <= 0) throw new Error("Slack OAuth token response has an invalid expires_in.");
2257
+ return {
2258
+ credentials: { SLACK_API_KEY: response.access_token },
2259
+ tokens: {
2260
+ accessToken: response.access_token,
2261
+ refreshToken: response.refresh_token,
2262
+ expiresAt: response.expires_in === void 0 ? void 0 : new Date(Date.now() + response.expires_in * 1e3).toISOString()
732
2263
  }
733
- }) : Effect.fail(new StepFailure({ message: "Template mode requires emailTemplateId." }));
734
- if (contentMode === "html") return input.emailHtml ? Effect.succeed({
735
- ...basePayload,
736
- html: input.emailHtml,
737
- ...omitBy({ text: input.emailBody }, isNil)
738
- }) : Effect.fail(new StepFailure({ message: "HTML mode requires emailHtml." }));
739
- return input.emailBody ? Effect.succeed({
740
- ...basePayload,
741
- text: input.emailBody,
742
- ...omitBy({ html: input.emailHtml }, isNil)
743
- }) : Effect.fail(new StepFailure({ message: "Text mode requires emailBody." }));
2264
+ };
744
2265
  }
745
- const resend = defineIntegration({
746
- type: "resend",
747
- label: "Resend",
748
- description: "Send transactional emails",
749
- credentials: resendCredentialFields,
750
- test: async () => (await import("./test-B8I6nxMN.js")).testResend,
751
- actions: { "send-email": {
752
- label: "Send Email",
753
- description: "Send an email via Resend",
754
- sideEffect: true,
755
- input: sendEmailInput,
756
- output: sendEmailOutput,
757
- configFields: [
758
- {
759
- key: "emailFrom",
760
- label: "From (Sender)",
761
- type: "template-input",
762
- placeholder: "Your Name <noreply@example.com>",
763
- example: "Support <support@example.com>"
764
- },
765
- {
766
- key: "emailTo",
767
- label: "To",
768
- type: "template-input",
769
- placeholder: "recipient@example.com",
770
- example: "user@example.com",
771
- required: true
772
- },
773
- {
774
- key: "testBehavior",
775
- label: "Test Mode Behavior",
776
- type: "select",
777
- defaultValue: "log_only",
778
- options: [{
779
- value: "log_only",
780
- label: "Log only (do nothing)"
781
- }, {
782
- value: "send_to_test_email",
783
- label: "Send to test email"
784
- }]
785
- },
786
- {
787
- key: "testEmailTo",
788
- label: "Test Email Address",
789
- type: "text",
790
- placeholder: "test@example.com",
791
- literal: true,
792
- showWhen: {
793
- field: "testBehavior",
794
- equals: "send_to_test_email"
795
- }
796
- },
797
- {
798
- key: "emailSubject",
799
- label: "Subject",
800
- type: "template-input",
801
- placeholder: "Subject or {{NodeName.title}}",
802
- example: "Hello from my workflow",
803
- required: true
804
- },
805
- {
806
- key: "emailContentMode",
807
- label: "Content Mode",
808
- type: "select",
809
- defaultValue: "text",
810
- options: [
811
- {
812
- value: "text",
813
- label: "Text"
814
- },
815
- {
816
- value: "html",
817
- label: "HTML"
818
- },
819
- {
820
- value: "template",
821
- label: "Template"
822
- }
823
- ]
824
- },
825
- {
826
- key: "emailBody",
827
- label: "Text Body",
828
- type: "template-textarea",
829
- placeholder: "Email content or {{NodeName.description}}",
830
- rows: 5,
831
- example: "This is the email body content.",
832
- required: true,
833
- showWhen: {
834
- field: "emailContentMode",
835
- equals: "text"
836
- }
837
- },
838
- {
839
- key: "emailHtml",
840
- label: "HTML Body",
841
- type: "template-textarea",
842
- placeholder: "<p>Hello {{NodeName.name}}</p>",
843
- rows: 8,
844
- showWhen: {
845
- field: "emailContentMode",
846
- equals: "html"
847
- }
848
- },
849
- {
850
- key: "emailTemplateId",
851
- label: "Template ID",
852
- type: "template-input",
853
- placeholder: "tpl_xxxxxxxx",
854
- showWhen: {
855
- field: "emailContentMode",
856
- equals: "template"
857
- }
858
- },
859
- {
860
- key: "emailTemplateVariables",
861
- label: "Template Variables (JSON)",
862
- type: "template-textarea",
863
- rows: 6,
864
- placeholder: "{\"FIRST_NAME\":\"Alice\",\"APPOINTMENT_AT\":\"2026-03-10\"}",
865
- showWhen: {
866
- field: "emailContentMode",
867
- equals: "template"
868
- }
869
- },
870
- {
871
- type: "group",
872
- label: "Additional Recipients",
873
- fields: [
874
- {
875
- key: "emailCc",
876
- label: "CC",
877
- type: "template-input",
878
- placeholder: "cc@example.com",
879
- example: "manager@example.com"
880
- },
881
- {
882
- key: "emailBcc",
883
- label: "BCC",
884
- type: "template-input",
885
- placeholder: "bcc@example.com",
886
- example: "archive@example.com"
887
- },
888
- {
889
- key: "emailReplyTo",
890
- label: "Reply-To",
891
- type: "template-input",
892
- placeholder: "reply@example.com",
893
- example: "support@example.com"
894
- }
895
- ]
896
- },
897
- {
898
- type: "group",
899
- label: "Scheduling",
900
- fields: [{
901
- key: "emailScheduledAt",
902
- label: "Schedule At (ISO 8601)",
903
- type: "template-input",
904
- placeholder: "2024-12-25T09:00:00Z",
905
- example: "2024-12-25T09:00:00Z"
906
- }, {
907
- key: "emailTopicId",
908
- label: "Topic ID",
909
- type: "template-input",
910
- placeholder: "topic_abc123",
911
- example: "topic_abc123"
912
- }]
913
- },
914
- {
915
- type: "group",
916
- label: "Tags",
917
- fields: [{
918
- key: "emailTags",
919
- label: "",
920
- type: "key-value"
921
- }]
922
- }
923
- ],
924
- handler: Effect.fn(function* (bag) {
925
- const { input } = bag;
926
- const idempotencyKey = bag.executionId;
927
- const syntheticIdSuffix = idempotencyKey ?? "no_execution";
928
- const testBehavior = resolveResendTestBehavior(input.testBehavior);
929
- if (bag.runMode === "test" && testBehavior === "log_only") return {
930
- id: `resend:test-log-only:${syntheticIdSuffix}`,
931
- reasonCode: "test_mode_log_only"
932
- };
933
- const testRecipient = input.testEmailTo?.trim() ?? "";
934
- const routeToTestRecipient = bag.runMode === "test" && testBehavior === "send_to_test_email";
935
- if (routeToTestRecipient && testRecipient.length === 0) return {
936
- id: `resend:test-log-fallback:${syntheticIdSuffix}`,
937
- reasonCode: "test_mode_log_fallback_missing_test_email"
938
- };
939
- if (routeToTestRecipient && !isValidTestEmailAddress(testRecipient)) return {
940
- id: `resend:test-log-fallback:${syntheticIdSuffix}`,
941
- reasonCode: "test_mode_log_fallback_invalid_test_email"
2266
+ function createSlackOAuth(clientId, clientSecret) {
2267
+ const client = {
2268
+ clientId,
2269
+ clientSecret
2270
+ };
2271
+ return {
2272
+ label: "Slack",
2273
+ registerClient: () => client,
2274
+ authorize: ({ redirectUri, state }) => {
2275
+ const url = new URL("https://slack.com/oauth/v2/authorize");
2276
+ url.searchParams.set("client_id", client.clientId);
2277
+ url.searchParams.set("scope", "chat:write");
2278
+ url.searchParams.set("redirect_uri", redirectUri);
2279
+ url.searchParams.set("state", state);
2280
+ return url;
2281
+ },
2282
+ exchange: async ({ code, redirectUri }) => {
2283
+ const response = await requestSlackOAuth(client, SLACK_OAUTH_URL, new URLSearchParams({
2284
+ code,
2285
+ redirect_uri: redirectUri
2286
+ }), slackOAuthResponseSchema);
2287
+ return {
2288
+ ...tokenSet(response, { requireRefreshToken: false }),
2289
+ accountLabel: response.team?.name
942
2290
  };
943
- const credentials = yield* bag.credentials;
944
- const apiKey = credentials.RESEND_API_KEY;
945
- if (!apiKey) return yield* new StepFailure({ message: "RESEND_API_KEY is not configured. Please add it in Project Integrations." });
946
- const senderEmail = input.emailFrom || credentials.RESEND_FROM_EMAIL;
947
- if (!senderEmail) return yield* new StepFailure({ message: "No sender is configured. Please add it in the action or in Project Integrations." });
948
- const payload = yield* buildEmailPayload(input, senderEmail, routeToTestRecipient ? { to: testRecipient } : {
949
- to: input.emailTo,
950
- cc: input.emailCc,
951
- bcc: input.emailBcc
952
- });
953
- return { id: (yield* bag.step.run("send", sendResendEmail(apiKey, payload, idempotencyKey).pipe(Effect.mapError((error) => new StepFailure({ message: `Failed to send email: ${describeResendFailure(error)}` }))))).id };
954
- })
955
- } }
956
- });
957
- //#endregion
958
- //#region src/slack/index.ts
2291
+ },
2292
+ refresh: async ({ grant }) => {
2293
+ const currentRefreshToken = grant.tokens.refreshToken;
2294
+ if (!currentRefreshToken) throw new Error("Slack OAuth refresh requires the current refresh_token.");
2295
+ return tokenSet(await requestSlackOAuth(client, SLACK_OAUTH_URL, new URLSearchParams({
2296
+ grant_type: "refresh_token",
2297
+ refresh_token: currentRefreshToken
2298
+ }), slackOAuthResponseSchema), { requireRefreshToken: true });
2299
+ },
2300
+ revoke: async ({ grant }) => {
2301
+ await requestSlackOAuth({
2302
+ clientId: client.clientId,
2303
+ clientSecret: void 0
2304
+ }, SLACK_UNINSTALL_URL, new URLSearchParams({
2305
+ token: grant.tokens.accessToken,
2306
+ client_id: client.clientId,
2307
+ client_secret: client.clientSecret ?? ""
2308
+ }), slackOkResponseSchema, { basicAuth: false });
2309
+ }
2310
+ };
2311
+ }
2312
+ // src/slack/index.ts
959
2313
  /**
960
- * The Slack integration: its credentials, its one action, and what that action
961
- * does.
2314
+ * The Slack integration: its credentials, actions, and what those actions do.
962
2315
  *
963
2316
  * One file, because only the server imports it. The editor gets this plugin's
964
2317
  * metadata as JSON over `/api/extensions`, so nothing here reaches a browser
965
- * bundle. The icon is the exception, since a React component cannot be
2318
+ * bundle. The icon is the exception, because a React component cannot be
966
2319
  * serialized: it stays in `ui.ts`, which only the browser imports.
967
2320
  */
968
2321
  const slackCredentialFields = { SLACK_API_KEY: {
@@ -999,81 +2352,167 @@ const sendSlackMessageInput = Schema.Struct({
999
2352
  * nothing.
1000
2353
  */
1001
2354
  const sendSlackMessageOutput = Schema.Struct({
1002
- ts: Schema.String.annotate({ description: "Message timestamp" }),
1003
- channel: Schema.String.annotate({ description: "Channel ID" }),
2355
+ ts: Schema.String.annotate({ title: "Message timestamp" }),
2356
+ channel: Schema.String.annotate({ title: "Channel ID" }),
1004
2357
  /** Absent on a real send: this is why a test run did not make one. */
1005
2358
  reasonCode: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Why a test run did not send" })))
1006
2359
  });
2360
+ const replyToThreadInput = Schema.Struct({
2361
+ slackChannel: Schema.String,
2362
+ slackThreadTs: Schema.String,
2363
+ slackMessage: Schema.String,
2364
+ testBehavior: Schema.optionalKey(Schema.String)
2365
+ });
2366
+ const replyToThreadOutput = Schema.Struct({
2367
+ ts: Schema.String.annotate({ title: "Message timestamp" }),
2368
+ channel: Schema.String.annotate({ title: "Channel ID" }),
2369
+ threadTs: Schema.String.annotate({ title: "Parent message timestamp" }),
2370
+ reasonCode: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Why a test run did not send" })))
2371
+ });
1007
2372
  function resolveSlackTestBehavior(value) {
1008
2373
  return value === "send_message" ? "send_message" : "log_only";
1009
2374
  }
1010
- const slack = defineIntegration({
1011
- type: "slack",
1012
- label: "Slack",
1013
- description: "Send messages to Slack channels",
1014
- credentials: slackCredentialFields,
1015
- test: async () => (await import("./test-8wDSAiFQ.js")).testSlack,
1016
- actions: { "send-message": {
1017
- label: "Send Slack Message",
1018
- description: "Send a message to a Slack channel",
1019
- sideEffect: true,
1020
- input: sendSlackMessageInput,
1021
- output: sendSlackMessageOutput,
1022
- configFields: [
1023
- {
1024
- key: "slackChannel",
1025
- label: "Channel",
1026
- type: "text",
1027
- placeholder: "#general or {{NodeName.channel}}",
1028
- example: "#general",
1029
- required: true
1030
- },
1031
- {
1032
- key: "slackMessage",
1033
- label: "Message",
1034
- type: "template-textarea",
1035
- placeholder: "Your message. Use {{NodeName.field}} to insert data from previous nodes.",
1036
- rows: 4,
1037
- example: "Hello from my workflow!",
1038
- required: true
2375
+ const slack = (options) => {
2376
+ const clientId = options?.oauthClient?.clientId?.trim();
2377
+ const clientSecret = options?.oauthClient?.clientSecret?.trim();
2378
+ if (Boolean(clientId) !== Boolean(clientSecret)) throw new Error("Slack OAuth requires non-empty oauthClient.clientId and oauthClient.clientSecret.");
2379
+ return defineIntegration({
2380
+ type: "slack",
2381
+ label: "Slack",
2382
+ description: "Send messages to Slack channels and threads",
2383
+ credentials: slackCredentialFields,
2384
+ oauth: clientId && clientSecret ? createSlackOAuth(clientId, clientSecret) : void 0,
2385
+ test: async () => (await import("./test-x2qD6zdE.js")).testSlack,
2386
+ actions: {
2387
+ "send-message": {
2388
+ label: "Send Slack Message",
2389
+ description: "Send a message to a Slack channel",
2390
+ sideEffect: true,
2391
+ input: sendSlackMessageInput,
2392
+ output: sendSlackMessageOutput,
2393
+ configFields: [
2394
+ {
2395
+ key: "slackChannel",
2396
+ label: "Channel",
2397
+ type: "text",
2398
+ placeholder: "#general or {{NodeName.channel}}",
2399
+ example: "#general",
2400
+ required: true
2401
+ },
2402
+ {
2403
+ key: "slackMessage",
2404
+ label: "Message",
2405
+ type: "template-textarea",
2406
+ placeholder: "Your message. Use {{NodeName.field}} to insert data from previous nodes.",
2407
+ rows: 4,
2408
+ example: "Hello from my workflow!",
2409
+ required: true
2410
+ },
2411
+ {
2412
+ key: "testBehavior",
2413
+ label: "Test Mode Behavior",
2414
+ type: "select",
2415
+ defaultValue: "log_only",
2416
+ options: [{
2417
+ value: "log_only",
2418
+ label: "Log only (do nothing)"
2419
+ }, {
2420
+ value: "send_message",
2421
+ label: "Send real Slack message"
2422
+ }]
2423
+ }
2424
+ ],
2425
+ handler: Effect.fn(function* (bag) {
2426
+ const { input } = bag;
2427
+ const testBehavior = resolveSlackTestBehavior(input.testBehavior);
2428
+ if (bag.runMode === "test" && testBehavior === "log_only") return {
2429
+ ts: "",
2430
+ channel: input.slackChannel,
2431
+ reasonCode: "test_mode_log_only"
2432
+ };
2433
+ const apiKey = (yield* bag.credentials).SLACK_API_KEY;
2434
+ if (!apiKey) return yield* new StepFailure({ message: "SLACK_API_KEY is not configured. Please add it in Project Integrations." });
2435
+ const posted = yield* bag.step.run("post", callSlack(apiKey, "chat.postMessage", postMessageSchema, { body: {
2436
+ channel: input.slackChannel,
2437
+ text: input.slackMessage
2438
+ } }).pipe(Effect.mapError((error) => new StepFailure({ message: `Failed to send Slack message: ${describeSlackFailure(error)}` }))));
2439
+ return {
2440
+ ts: posted.ts,
2441
+ channel: posted.channel
2442
+ };
2443
+ })
1039
2444
  },
1040
- {
1041
- key: "testBehavior",
1042
- label: "Test Mode Behavior",
1043
- type: "select",
1044
- defaultValue: "log_only",
1045
- options: [{
1046
- value: "log_only",
1047
- label: "Log only (do nothing)"
1048
- }, {
1049
- value: "send_message",
1050
- label: "Send real Slack message"
1051
- }]
2445
+ "reply-to-thread": {
2446
+ label: "Reply to Slack Thread",
2447
+ description: "Send a reply to a Slack message thread",
2448
+ sideEffect: true,
2449
+ input: replyToThreadInput,
2450
+ output: replyToThreadOutput,
2451
+ configFields: [
2452
+ {
2453
+ key: "slackChannel",
2454
+ label: "Channel ID",
2455
+ type: "template-input",
2456
+ placeholder: "C1234567890",
2457
+ required: true
2458
+ },
2459
+ {
2460
+ key: "slackThreadTs",
2461
+ label: "Parent Message Timestamp",
2462
+ type: "template-input",
2463
+ placeholder: "1739.123456",
2464
+ required: true
2465
+ },
2466
+ {
2467
+ key: "slackMessage",
2468
+ label: "Message",
2469
+ type: "template-textarea",
2470
+ placeholder: "Your reply. Use {{NodeName.field}} to insert data from previous nodes.",
2471
+ rows: 4,
2472
+ example: "Following up in this thread.",
2473
+ required: true
2474
+ },
2475
+ {
2476
+ key: "testBehavior",
2477
+ label: "Test Mode Behavior",
2478
+ type: "select",
2479
+ defaultValue: "log_only",
2480
+ options: [{
2481
+ value: "log_only",
2482
+ label: "Log only (do nothing)"
2483
+ }, {
2484
+ value: "send_message",
2485
+ label: "Send real Slack message"
2486
+ }]
2487
+ }
2488
+ ],
2489
+ handler: Effect.fn(function* (bag) {
2490
+ const { input } = bag;
2491
+ const testBehavior = resolveSlackTestBehavior(input.testBehavior);
2492
+ if (bag.runMode === "test" && testBehavior === "log_only") return {
2493
+ ts: "",
2494
+ channel: input.slackChannel,
2495
+ threadTs: input.slackThreadTs,
2496
+ reasonCode: "test_mode_log_only"
2497
+ };
2498
+ const apiKey = (yield* bag.credentials).SLACK_API_KEY;
2499
+ if (!apiKey) return yield* new StepFailure({ message: "SLACK_API_KEY is not configured. Please add it in Project Integrations." });
2500
+ const posted = yield* bag.step.run("post", callSlack(apiKey, "chat.postMessage", postMessageSchema, { body: {
2501
+ channel: input.slackChannel,
2502
+ text: input.slackMessage,
2503
+ thread_ts: input.slackThreadTs
2504
+ } }).pipe(Effect.mapError((error) => new StepFailure({ message: `Failed to reply to Slack thread: ${describeSlackFailure(error)}` }))));
2505
+ return {
2506
+ ts: posted.ts,
2507
+ channel: posted.channel,
2508
+ threadTs: input.slackThreadTs
2509
+ };
2510
+ })
1052
2511
  }
1053
- ],
1054
- handler: Effect.fn(function* (bag) {
1055
- const { input } = bag;
1056
- const testBehavior = resolveSlackTestBehavior(input.testBehavior);
1057
- if (bag.runMode === "test" && testBehavior === "log_only") return {
1058
- ts: "",
1059
- channel: input.slackChannel,
1060
- reasonCode: "test_mode_log_only"
1061
- };
1062
- const apiKey = (yield* bag.credentials).SLACK_API_KEY;
1063
- if (!apiKey) return yield* new StepFailure({ message: "SLACK_API_KEY is not configured. Please add it in Project Integrations." });
1064
- const posted = yield* bag.step.run("post", callSlack(apiKey, "chat.postMessage", postMessageSchema, { body: {
1065
- channel: input.slackChannel,
1066
- text: input.slackMessage
1067
- } }).pipe(Effect.mapError((error) => new StepFailure({ message: `Failed to send Slack message: ${describeSlackFailure(error)}` }))));
1068
- return {
1069
- ts: posted.ts,
1070
- channel: posted.channel
1071
- };
1072
- })
1073
- } }
1074
- });
1075
- //#endregion
1076
- //#region src/twilio/index.ts
2512
+ }
2513
+ });
2514
+ };
2515
+ // src/twilio/index.ts
1077
2516
  /**
1078
2517
  * The Twilio integration: its credentials, its actions, and what each action
1079
2518
  * does.
@@ -1081,8 +2520,8 @@ const slack = defineIntegration({
1081
2520
  * One file, because only the server imports it. The editor gets this plugin's
1082
2521
  * metadata as JSON over `/api/extensions`, so nothing here reaches a browser
1083
2522
  * bundle and the system client below costs the browser nothing. The icon is the
1084
- * exception, since a React component cannot be serialized: it stays in `ui.ts`,
1085
- * which only the browser imports.
2523
+ * exception, because a React component cannot be serialized: it stays in
2524
+ * `ui.ts`, which only the browser imports.
1086
2525
  */
1087
2526
  const twilioCredentialFields = {
1088
2527
  TWILIO_ACCOUNT_SID: {
@@ -1154,11 +2593,11 @@ const sendSmsInput = Schema.Struct({
1154
2593
  * holds `undefined`, which is why the handler writes the null.
1155
2594
  */
1156
2595
  const sendSmsOutput = Schema.Struct({
1157
- sid: Schema.String.annotate({ description: "Message SID" }),
1158
- status: Schema.String.annotate({ description: "Delivery status" }),
1159
- to: Schema.String.annotate({ description: "Recipient phone number" }),
1160
- from: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Sender phone number" }))),
1161
- messagingServiceSid: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Messaging Service SID" }))),
2596
+ sid: Schema.String.annotate({ title: "Message SID" }),
2597
+ status: Schema.String.annotate({ title: "Delivery status" }),
2598
+ to: Schema.String.annotate({ title: "Recipient phone number" }),
2599
+ from: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ title: "Sender phone number" }))),
2600
+ messagingServiceSid: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ title: "Messaging Service SID" }))),
1162
2601
  /** Absent on a real send: this is why a test run did not make one. */
1163
2602
  reasonCode: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({ description: "Why a test run did not send" })))
1164
2603
  });
@@ -1170,7 +2609,7 @@ const twilio = defineIntegration({
1170
2609
  label: "Twilio",
1171
2610
  description: "Send SMS messages with Twilio Programmable Messaging",
1172
2611
  credentials: twilioCredentialFields,
1173
- test: async () => (await import("./test-DN4ZApY4.js")).testTwilio,
2612
+ test: async () => (await import("./test-Beetxizr.js")).testTwilio,
1174
2613
  actions: { "send-sms": {
1175
2614
  label: "Send SMS",
1176
2615
  description: "Send an SMS via Twilio",
@@ -1225,12 +2664,14 @@ const twilio = defineIntegration({
1225
2664
  key: "smsFrom",
1226
2665
  label: "From Number",
1227
2666
  type: "template-input",
1228
- placeholder: "+15557654321"
2667
+ placeholder: "+15557654321",
2668
+ connectionDefaultKey: "TWILIO_FROM_NUMBER"
1229
2669
  }, {
1230
2670
  key: "smsMessagingServiceSid",
1231
2671
  label: "Messaging Service SID",
1232
2672
  type: "template-input",
1233
- placeholder: "MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
2673
+ placeholder: "MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
2674
+ connectionDefaultKey: "TWILIO_MESSAGING_SERVICE_SID"
1234
2675
  }]
1235
2676
  },
1236
2677
  {
@@ -1304,14 +2745,13 @@ const twilio = defineIntegration({
1304
2745
  })
1305
2746
  } }
1306
2747
  });
1307
- //#endregion
1308
- //#region src/index.ts
1309
- const builtInIntegrations = [
2748
+ // src/index.ts
2749
+ const builtInIntegrations = (options) => [
1310
2750
  clerk,
1311
2751
  linear,
2752
+ posthog,
1312
2753
  resend,
1313
- slack,
2754
+ slack(options?.slack),
1314
2755
  twilio
1315
2756
  ];
1316
- //#endregion
1317
- export { builtInIntegrations, clerk, linear, resend, slack, twilio };
2757
+ export { builtInIntegrations, clerk, linear, posthog, resend, slack, twilio };