Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/fuzzy-melons-email.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"eve": patch
---

Add guided Resend email setup through `eve add channel/resend`, with portable credentials or a generic Vercel Connect API-key connector, deployment, and webhook reconciliation.
Add guided Resend email setup through `eve add channel/resend`, with Vercel-domain provisioning, existing-account authorization, manual credentials, deployment, and webhook reconciliation.
7 changes: 1 addition & 6 deletions apps/docs/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -1723,12 +1723,7 @@
"type": "registry:item",
"title": "Resend",
"description": "Send and receive threaded email through Resend via the Chat SDK.",
"dependencies": [
"chat",
"@resend/chat-sdk-adapter",
"@chat-adapter/state-memory",
"@vercel/connect"
],
"dependencies": ["chat", "@resend/chat-sdk-adapter", "@chat-adapter/state-memory"],
"meta": {
"eve": {
"setup": {
Expand Down
5 changes: 5 additions & 0 deletions packages/eve/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,11 @@
"import": "./dist/src/public/channels/photon/index.js",
"default": "./dist/src/public/channels/photon/index.js"
},
"./channels/resend": {
"types": "./dist/src/public/channels/resend/index.d.ts",
"import": "./dist/src/public/channels/resend/index.js",
"default": "./dist/src/public/channels/resend/index.js"
},
"./channels/github": {
"types": "./dist/src/public/channels/github/index.d.ts",
"import": "./dist/src/public/channels/github/index.js",
Expand Down
30 changes: 26 additions & 4 deletions packages/eve/src/public/channels/chat-sdk/chatSdkChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ const ActiveWebhookKey = new ContextKey<ActiveWebhookContext>("chat-sdk.active-w
*/
export interface ChatSdkChannelState extends Record<string, unknown> {
thread: SerializedThread | null;
/** Adapter-specific JSON captured at inbound dispatch for durable restoration. */
adapterContext?: unknown;
/** Message id of the in-flight streamed assistant post (edit fallback). */
anchorMessageId?: string | null;
/**
Expand Down Expand Up @@ -175,6 +177,19 @@ export interface ChatSdkChannelConfig<
readonly webhook?: Omit<WebhookOptions, "waitUntil">;
/** Optional Eve event handlers. Supplied handlers replace built-in defaults. */
readonly events?: ChatSdkChannelEvents<TAdapters>;
/**
* Restores adapter-owned transient context from eve's durable serialized
* thread before an outbound workflow event uses the reconstructed thread.
*/
readonly captureAdapterContext?: (input: {
readonly adapter: Adapter;
readonly thread: SerializedThread;
}) => unknown;
readonly restoreAdapterContext?: (input: {
readonly adapter: Adapter;
readonly context: unknown;
readonly thread: SerializedThread;
}) => void;
/**
* Prefix for default Eve HITL button action ids. Change this if your Chat SDK
* app already uses the `eve_input:` prefix.
Expand Down Expand Up @@ -267,6 +282,7 @@ export function chatSdkChannel<TAdapters extends ChatSdkAdapters>(
}
await bridgeSend(
bot,
config.captureAdapterContext,
{ inputResponses: [response] },
{
auth: config.resolveInputAuth ? await config.resolveInputAuth(event) : null,
Expand All @@ -290,7 +306,7 @@ export function chatSdkChannel<TAdapters extends ChatSdkAdapters>(
state,
streaming,
streamingEditIntervalMs,
thread: threadFromState(bot, state),
thread: threadFromState(bot, state, config.restoreAdapterContext),
};
},
// Register both methods on each adapter's webhook path. Providers such as X
Expand Down Expand Up @@ -331,7 +347,7 @@ export function chatSdkChannel<TAdapters extends ChatSdkAdapters>(
bot,
channel,
send(input, options) {
return bridgeSend(bot, input, options);
return bridgeSend(bot, config.captureAdapterContext, input, options);
},
};
}
Expand Down Expand Up @@ -544,6 +560,7 @@ async function postFailure(

async function bridgeSend<TAdapters extends ChatSdkAdapters>(
bot: Chat<TAdapters>,
captureAdapterContext: ChatSdkChannelConfig<TAdapters>["captureAdapterContext"],
input: ChatSdkSendInput,
options: ChatSdkSendOptions,
): Promise<Session> {
Expand All @@ -554,10 +571,12 @@ async function bridgeSend<TAdapters extends ChatSdkAdapters>(
);
}
const thread = serializeThread(bot, options.thread, options.adapterName);
const adapter = bot.getAdapter(thread.adapterName);
const adapterContext = captureAdapterContext?.({ adapter, thread });
const sendOptions: SendOptions<ChatSdkChannelState> = {
auth: options.auth ?? null,
continuationToken: thread.id,
state: { thread },
state: adapterContext === undefined ? { thread } : { adapterContext, thread },
};
if (options.callback) {
sendOptions.callback = options.callback;
Expand Down Expand Up @@ -594,12 +613,15 @@ function metadataFromState(state: ChatSdkChannelState): ChatSdkInstrumentationMe
function threadFromState<TAdapters extends ChatSdkAdapters>(
bot: Chat<TAdapters>,
state: ChatSdkChannelState,
restoreAdapterContext: ChatSdkChannelConfig<TAdapters>["restoreAdapterContext"],
): Thread | null {
if (!state.thread) return null;
try {
const serialized = state.thread;
const adapter = bot.getAdapter(serialized.adapterName);
restoreAdapterContext?.({ adapter, context: state.adapterContext, thread: serialized });
return new ThreadImpl({
adapter: bot.getAdapter(serialized.adapterName),
adapter,
channelId: serialized.channelId,
channelVisibility: serialized.channelVisibility,
currentMessage: serialized.currentMessage
Expand Down
71 changes: 71 additions & 0 deletions packages/eve/src/public/channels/resend/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";

import { captureResendReplyContext, restoreResendReplyContext } from "./index.js";

describe("restoreResendReplyContext", () => {
it("seeds subject and reply message ids from a durable serialized message", () => {
const trackMessage = vi.fn();
const trackSubject = vi.fn();
const adapter = {
name: "resend",
threadResolver: { trackMessage, trackSubject },
};
const thread = {
_type: "chat:Thread",
adapterName: "resend",
channelId: "resend:ben@example.com",
id: "resend:ben@example.com:root",
isDM: false,
currentMessage: {
_type: "chat:Message",
id: "email-1",
threadId: "resend:ben@example.com:root",
text: "hello",
formatted: { type: "root", children: [] },
raw: {
subject: "Eve test",
messageId: "<current@example.com>",
headers: { References: "<root@example.com> <previous@example.com>" },
},
author: {
userId: "ben@example.com",
userName: "ben@example.com",
fullName: "Ben",
isBot: false,
isMe: false,
isSystem: false,
},
metadata: { dateSent: new Date().toISOString(), edited: false },
attachments: [],
isMention: true,
links: [],
},
};
const context = captureResendReplyContext({ adapter: adapter as never, thread });
restoreResendReplyContext({ adapter: adapter as never, context, thread });

expect(context).toMatchObject({ messageId: "<current@example.com>", subject: "Eve test" });
expect(trackSubject).toHaveBeenCalledWith("resend:ben@example.com:root", "Eve test");
expect(trackMessage.mock.calls.map((call) => call[1])).toEqual([
"<root@example.com>",
"<previous@example.com>",
"<current@example.com>",
]);
});

it("ignores adapters that do not expose the experimental resolver", () => {
expect(() =>
restoreResendReplyContext({
adapter: { name: "resend" } as never,
context: undefined,
thread: {
_type: "chat:Thread",
adapterName: "resend",
channelId: "resend:user@example.com",
id: "resend:user@example.com:root",
isDM: false,
},
}),
).not.toThrow();
});
});
93 changes: 93 additions & 0 deletions packages/eve/src/public/channels/resend/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { Adapter, SerializedThread } from "#compiled/chat/index.js";

interface ResendRawReplyContext {
messageId: string;
subject: string;
headers?: Record<string, string>;
}

interface ResendThreadResolver {
trackMessage(threadId: string, messageId: string): void;
trackSubject(threadId: string, subject: string): void;
}

interface ResendAdapterWithResolver extends Adapter {
threadResolver?: ResendThreadResolver;
}

function rawReplyContext(value: unknown): ResendRawReplyContext | undefined {
const raw = value;
if (typeof raw !== "object" || raw === null) return undefined;
const record = raw as Record<string, unknown>;
if (typeof record.messageId !== "string" || typeof record.subject !== "string") {
return undefined;
}
const headers =
typeof record.headers === "object" && record.headers !== null
? Object.fromEntries(
Object.entries(record.headers).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
)
: undefined;
const context: ResendRawReplyContext = {
messageId: record.messageId,
subject: record.subject,
};
if (headers !== undefined) context.headers = headers;
return context;
}

function referenceMessageIds(headers: Record<string, string> | undefined): string[] {
const references = headers?.References ?? headers?.references;
if (!references) return [];
const trimmed = references.trim();
if (trimmed.startsWith("[")) {
try {
const parsed = JSON.parse(trimmed) as unknown;
if (Array.isArray(parsed)) {
return parsed.filter((value): value is string => typeof value === "string");
}
} catch {
// Malformed provider headers fall back to RFC whitespace parsing below.
}
}
return trimmed.split(/\s+/u).filter(Boolean);
}

/** Captures the inbound Resend raw message into eve's durable channel state. */
export function captureResendReplyContext(input: {
readonly adapter: Adapter;
readonly thread: SerializedThread;
}): ResendRawReplyContext | undefined {
if (input.adapter.name !== "resend") return undefined;
return rawReplyContext(input.thread.currentMessage?.raw);
}

/**
* Restores the official Resend adapter's reply metadata from eve's durable
* channel state. This experimental bridge keeps workflow replies in the inbound
* email thread until the adapter exposes a public restoration API.
*/
export function restoreResendReplyContext(input: {
readonly adapter: Adapter;
readonly context: unknown;
readonly thread: SerializedThread;
}): void {
if (input.adapter.name !== "resend") return;
const context = rawReplyContext(input.context);
if (context === undefined) return;
const resolver = (input.adapter as ResendAdapterWithResolver).threadResolver;
if (
resolver === undefined ||
typeof resolver.trackMessage !== "function" ||
typeof resolver.trackSubject !== "function"
) {
return;
}
resolver.trackSubject(input.thread.id, context.subject);
for (const messageId of referenceMessageIds(context.headers)) {
resolver.trackMessage(input.thread.id, messageId);
}
resolver.trackMessage(input.thread.id, context.messageId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from "vitest";

import { createFakePrompter } from "#internal/testing/fake-prompter.js";
import {
authorizeResendMarketplaceSetup,
createResendApiKey,
deleteResendApiKey,
type MarketplaceOAuthDeps,
} from "./marketplace-oauth.js";

function effects(outputs: Array<{ ok: boolean; stdout: string }>): MarketplaceOAuthDeps {
return {
fetch: vi.fn(),
runVercelCaptureStdout: vi.fn(async () => outputs.shift() ?? { ok: true, stdout: "{}" }),
};
}

describe("Resend Marketplace setup OAuth", () => {
it("creates and deletes a dedicated full-access API key", async () => {
const fetch = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValueOnce(
new Response(JSON.stringify({ id: "key_1", token: "re_dedicated" }), { status: 200 }),
)
.mockResolvedValueOnce(new Response(null, { status: 204 }));

await expect(
createResendApiKey({
accessToken: "oauth_secret",
name: "eve · weather",
deps: { fetch },
}),
).resolves.toEqual({ id: "key_1", token: "re_dedicated" });
expect(fetch.mock.calls[0]?.[1]?.body).toBe(
JSON.stringify({ name: "eve · weather", permission: "full_access" }),
);
await deleteResendApiKey({
accessToken: "oauth_secret",
id: "key_1",
deps: { fetch },
});
expect(fetch.mock.calls[1]?.[0]).toBe("https://api.resend.com/api-keys/key_1");
});
it("authorizes full_access and removes the temporary connector after cleanup", async () => {
const deps = effects([
{
ok: true,
stdout: JSON.stringify({
id: "scl_setup",
uid: "oauth/eve-resend-setup",
supportedSubjectTypes: ["user"],
}),
},
{ ok: true, stdout: JSON.stringify({ token: "oauth_secret" }) },
{ ok: true, stdout: JSON.stringify({ deleted: 1 }) },
{ ok: true, stdout: JSON.stringify({ removed: true }) },
]);

const authorization = await authorizeResendMarketplaceSetup({
log: createFakePrompter().prompter.log,
projectRoot: "/project",
orgId: "team",
deps,
});
expect(authorization.accessToken).toBe("oauth_secret");
await authorization.cleanup();

const calls = vi.mocked(deps.runVercelCaptureStdout).mock.calls.map((call) => call[0]);
expect(calls[1]).toEqual(
expect.arrayContaining([
"connect",
"token",
"oauth/eve-resend-setup",
"--scopes",
"full_access",
"--yes",
]),
);
expect(calls[2]).toEqual(
expect.arrayContaining(["connect", "revoke-tokens", "--my-tokens", "--yes"]),
);
expect(calls[3]).toEqual(
expect.arrayContaining(["connect", "remove", "--disconnect-all", "--yes"]),
);
});
});
Loading