@wfgraph/plugins 3.1.0 → 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/README.md +23 -6
- package/dist/client-B0M2Cle4.js +115 -0
- package/dist/{client-BEcFnbOB.js → client-B0dhD5OA.js} +1 -2
- package/dist/{client-DDDCNLId.js → client-BQmwEhSH.js} +1 -2
- package/dist/{client-LRwmIiPU.js → client-C32OdgmT.js} +5 -6
- package/dist/client-D3ALurAR.js +167 -0
- package/dist/config-options-BgSb_XqZ.js +101 -0
- package/dist/{errors-C4i6-DQE.js → errors-DQPf9k4I.js} +31 -24
- package/dist/index.d.ts +185 -53
- package/dist/index.js +1887 -447
- package/dist/omit-undefined-CnDMZgmp.js +6 -0
- package/dist/{test-B8I6nxMN.js → test-B5WBOt4r.js} +7 -6
- package/dist/{test-DN4ZApY4.js → test-Beetxizr.js} +2 -3
- package/dist/test-CTNffdZM.js +50 -0
- package/dist/{test-DWVpiQNl.js → test-CTWGAckT.js} +2 -3
- package/dist/{test-Dsrib2EJ.js → test-CiIt-3_x.js} +2 -3
- package/dist/{test-8wDSAiFQ.js → test-x2qD6zdE.js} +2 -3
- package/dist/ui.d.ts +11 -19
- package/dist/ui.js +119 -73
- package/package.json +15 -11
- package/skills/wfgraph-plugins/SKILL.md +106 -0
- package/dist/client-C2NIQxhb.js +0 -64
package/dist/index.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t as
|
|
3
|
-
import {
|
|
4
|
-
import { n as
|
|
5
|
-
import { n as
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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({
|
|
90
|
-
firstName: Schema.optionalKey(Schema.NullOr(Schema.String
|
|
91
|
-
lastName: Schema.optionalKey(Schema.NullOr(Schema.String
|
|
92
|
-
primaryEmailAddress: Schema.optionalKey(Schema.NullOr(Schema.String
|
|
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({
|
|
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-
|
|
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 =
|
|
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
|
-
}
|
|
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 =
|
|
284
|
+
const updatePayload = omitUndefined({
|
|
283
285
|
firstName: input.firstName,
|
|
284
286
|
lastName: input.lastName,
|
|
285
287
|
publicMetadata,
|
|
286
288
|
privateMetadata
|
|
287
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
362
|
-
url: Schema.String.annotate({
|
|
363
|
-
title: Schema.String.annotate({
|
|
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({
|
|
398
|
-
title: Schema.String.annotate({
|
|
399
|
-
url: Schema.String.annotate({
|
|
400
|
-
state: Schema.String.annotate({
|
|
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({
|
|
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({
|
|
406
|
-
|
|
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
|
|
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-
|
|
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
|
-
|
|
590
|
-
//#region src/resend/index.ts
|
|
592
|
+
// src/posthog/capture.ts
|
|
591
593
|
/**
|
|
592
|
-
* The
|
|
593
|
-
*
|
|
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
|
|
598
|
-
*
|
|
599
|
-
*
|
|
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
|
|
624
|
-
* editor writes text, and a template variable resolves to
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
|
2227
|
+
return `Slack OAuth request failed: HTTP ${error.status}`;
|
|
688
2228
|
}
|
|
689
|
-
function
|
|
690
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
if (
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
|
|
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,
|
|
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,
|
|
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({
|
|
1003
|
-
channel: Schema.String.annotate({
|
|
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 =
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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,
|
|
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({
|
|
1158
|
-
status: Schema.String.annotate({
|
|
1159
|
-
to: Schema.String.annotate({
|
|
1160
|
-
from: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({
|
|
1161
|
-
messagingServiceSid: Schema.optionalKey(Schema.NullOr(Schema.String.annotate({
|
|
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-
|
|
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
|
-
|
|
1308
|
-
|
|
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
|
-
|
|
1317
|
-
export { builtInIntegrations, clerk, linear, resend, slack, twilio };
|
|
2757
|
+
export { builtInIntegrations, clerk, linear, posthog, resend, slack, twilio };
|