diff --git a/.changeset/fuzzy-melons-email.md b/.changeset/fuzzy-melons-email.md index 6da2accbf2..056531e5ce 100644 --- a/.changeset/fuzzy-melons-email.md +++ b/.changeset/fuzzy-melons-email.md @@ -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. diff --git a/apps/docs/registry.json b/apps/docs/registry.json index b60ee54852..984da783f5 100644 --- a/apps/docs/registry.json +++ b/apps/docs/registry.json @@ -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": { diff --git a/packages/eve/package.json b/packages/eve/package.json index e262d6a2c8..c4a020f9e9 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -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", diff --git a/packages/eve/src/public/channels/chat-sdk/chatSdkChannel.ts b/packages/eve/src/public/channels/chat-sdk/chatSdkChannel.ts index 516cd531ec..39befe15fe 100644 --- a/packages/eve/src/public/channels/chat-sdk/chatSdkChannel.ts +++ b/packages/eve/src/public/channels/chat-sdk/chatSdkChannel.ts @@ -63,6 +63,8 @@ const ActiveWebhookKey = new ContextKey("chat-sdk.active-w */ export interface ChatSdkChannelState extends Record { 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; /** @@ -175,6 +177,19 @@ export interface ChatSdkChannelConfig< readonly webhook?: Omit; /** Optional Eve event handlers. Supplied handlers replace built-in defaults. */ readonly events?: ChatSdkChannelEvents; + /** + * 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. @@ -267,6 +282,7 @@ export function chatSdkChannel( } await bridgeSend( bot, + config.captureAdapterContext, { inputResponses: [response] }, { auth: config.resolveInputAuth ? await config.resolveInputAuth(event) : null, @@ -290,7 +306,7 @@ export function chatSdkChannel( 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 @@ -331,7 +347,7 @@ export function chatSdkChannel( bot, channel, send(input, options) { - return bridgeSend(bot, input, options); + return bridgeSend(bot, config.captureAdapterContext, input, options); }, }; } @@ -544,6 +560,7 @@ async function postFailure( async function bridgeSend( bot: Chat, + captureAdapterContext: ChatSdkChannelConfig["captureAdapterContext"], input: ChatSdkSendInput, options: ChatSdkSendOptions, ): Promise { @@ -554,10 +571,12 @@ async function bridgeSend( ); } const thread = serializeThread(bot, options.thread, options.adapterName); + const adapter = bot.getAdapter(thread.adapterName); + const adapterContext = captureAdapterContext?.({ adapter, thread }); const sendOptions: SendOptions = { auth: options.auth ?? null, continuationToken: thread.id, - state: { thread }, + state: adapterContext === undefined ? { thread } : { adapterContext, thread }, }; if (options.callback) { sendOptions.callback = options.callback; @@ -594,12 +613,15 @@ function metadataFromState(state: ChatSdkChannelState): ChatSdkInstrumentationMe function threadFromState( bot: Chat, state: ChatSdkChannelState, + restoreAdapterContext: ChatSdkChannelConfig["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 diff --git a/packages/eve/src/public/channels/resend/index.test.ts b/packages/eve/src/public/channels/resend/index.test.ts new file mode 100644 index 0000000000..66f7481771 --- /dev/null +++ b/packages/eve/src/public/channels/resend/index.test.ts @@ -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: "", + headers: { References: " " }, + }, + 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: "", subject: "Eve test" }); + expect(trackSubject).toHaveBeenCalledWith("resend:ben@example.com:root", "Eve test"); + expect(trackMessage.mock.calls.map((call) => call[1])).toEqual([ + "", + "", + "", + ]); + }); + + 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(); + }); +}); diff --git a/packages/eve/src/public/channels/resend/index.ts b/packages/eve/src/public/channels/resend/index.ts new file mode 100644 index 0000000000..98027d01a9 --- /dev/null +++ b/packages/eve/src/public/channels/resend/index.ts @@ -0,0 +1,93 @@ +import type { Adapter, SerializedThread } from "#compiled/chat/index.js"; + +interface ResendRawReplyContext { + messageId: string; + subject: string; + headers?: Record; +} + +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; + 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 | 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); +} diff --git a/packages/eve/src/setup/integrations/resend/marketplace-oauth.test.ts b/packages/eve/src/setup/integrations/resend/marketplace-oauth.test.ts new file mode 100644 index 0000000000..dc9d6d8615 --- /dev/null +++ b/packages/eve/src/setup/integrations/resend/marketplace-oauth.test.ts @@ -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() + .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"]), + ); + }); +}); diff --git a/packages/eve/src/setup/integrations/resend/marketplace-oauth.ts b/packages/eve/src/setup/integrations/resend/marketplace-oauth.ts new file mode 100644 index 0000000000..9878cef029 --- /dev/null +++ b/packages/eve/src/setup/integrations/resend/marketplace-oauth.ts @@ -0,0 +1,218 @@ +import { randomUUID } from "node:crypto"; + +import { createPromptCommandOutput, type ChannelSetupLog, withPhase } from "#setup/cli/index.js"; +import { runVercelCaptureStdout } from "#setup/primitives/run-vercel.js"; +import { z } from "zod"; + +const ConnectorSchema = z.object({ + id: z.string().min(1), + uid: z.string().min(1), + supportedSubjectTypes: z.array(z.string()).optional(), +}); +const TokenSchema = z.object({ token: z.string().min(1) }); +const ApiKeySchema = z.object({ + id: z.string().min(1), + token: z.string().min(1), +}); + +export interface MarketplaceOAuthDeps { + fetch: typeof fetch; + runVercelCaptureStdout: typeof runVercelCaptureStdout; +} + +const defaultDeps: MarketplaceOAuthDeps = { fetch, runVercelCaptureStdout }; + +function parseJson(stdout: string, description: string): unknown { + try { + return JSON.parse(stdout) as unknown; + } catch { + throw new Error(`Vercel returned invalid JSON for ${description}.`); + } +} + +async function cleanupSetupConnector(input: { + connectorUid: string; + projectRoot: string; + orgId: string; + signal?: AbortSignal; + deps: MarketplaceOAuthDeps; +}): Promise { + await input.deps.runVercelCaptureStdout( + [ + "connect", + "revoke-tokens", + input.connectorUid, + "--my-tokens", + "--yes", + "--format", + "json", + "--scope", + input.orgId, + ], + { cwd: input.projectRoot, nonInteractive: true, signal: input.signal }, + ); + const removed = await input.deps.runVercelCaptureStdout( + [ + "connect", + "remove", + input.connectorUid, + "--disconnect-all", + "--yes", + "--format", + "json", + "--scope", + input.orgId, + ], + { cwd: input.projectRoot, nonInteractive: true, signal: input.signal }, + ); + if (!removed.ok) { + throw new Error( + `Could not remove temporary connector ${input.connectorUid}. Run \`vercel connect remove ${input.connectorUid} --disconnect-all --yes\`.`, + ); + } +} + +/** Dedicated Resend API key created with a temporary setup authorization. */ +export interface CreatedResendApiKey { + readonly id: string; + readonly token: string; +} + +/** Creates a full-access API key for durable runtime use. */ +export async function createResendApiKey(input: { + accessToken: string; + name: string; + signal?: AbortSignal; + deps?: Pick; +}): Promise { + const response = await (input.deps ?? defaultDeps).fetch("https://api.resend.com/api-keys", { + method: "POST", + headers: { + Authorization: `Bearer ${input.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: input.name.slice(0, 50), permission: "full_access" }), + signal: input.signal, + }); + if (!response.ok) throw new Error(`Resend API-key creation failed with HTTP ${response.status}.`); + const parsed = ApiKeySchema.safeParse((await response.json()) as unknown); + if (!parsed.success) throw new Error("Resend returned an invalid API key."); + return parsed.data; +} + +/** Deletes a setup-created API key after a later failure. */ +export async function deleteResendApiKey(input: { + accessToken: string; + id: string; + signal?: AbortSignal; + deps?: Pick; +}): Promise { + const response = await (input.deps ?? defaultDeps).fetch( + `https://api.resend.com/api-keys/${encodeURIComponent(input.id)}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${input.accessToken}` }, + signal: input.signal, + }, + ); + if (!response.ok && response.status !== 404) { + throw new Error(`Resend API-key cleanup failed with HTTP ${response.status}.`); + } +} + +/** Temporary user OAuth authorization used only during guided setup. */ +export interface ResendSetupAuthorization { + readonly accessToken: string; + readonly connectorUid: string; + cleanup(): Promise; +} + +/** Authorizes Resend full access through Connect without reading project environment secrets. */ +export async function authorizeResendMarketplaceSetup(input: { + log: ChannelSetupLog; + projectRoot: string; + orgId: string; + signal?: AbortSignal; + deps?: MarketplaceOAuthDeps; +}): Promise { + const deps = input.deps ?? defaultDeps; + const onOutput = createPromptCommandOutput(input.log); + const name = `eve-resend-setup-${randomUUID().slice(0, 8)}`; + const created = await withPhase( + input.log, + "Creating a temporary Resend authorization...", + () => + deps.runVercelCaptureStdout( + [ + "connect", + "create", + "api.resend.com", + "--name", + name, + "--format", + "json", + "--scope", + input.orgId, + ], + { cwd: input.projectRoot, onOutput, signal: input.signal }, + ), + { kind: "external-action", emphasis: "browser" }, + ); + if (!created.ok) { + throw new Error("Could not create the temporary Resend OAuth connector."); + } + const connector = ConnectorSchema.safeParse( + parseJson(created.stdout, "the temporary Resend connector"), + ); + if (!connector.success || !connector.data.supportedSubjectTypes?.includes("user")) { + throw new Error("The temporary Resend connector does not support user OAuth authorization."); + } + + try { + const tokenResult = await withPhase( + input.log, + "Authorize Resend full access in the browser...", + () => + deps.runVercelCaptureStdout( + [ + "connect", + "token", + connector.data.uid, + "--scopes", + "full_access", + "--yes", + "--format", + "json", + "--scope", + input.orgId, + ], + { cwd: input.projectRoot, onOutput, signal: input.signal }, + ), + { kind: "external-action", emphasis: "browser" }, + ); + if (!tokenResult.ok) throw new Error("Resend OAuth authorization was not completed."); + const token = TokenSchema.safeParse(parseJson(tokenResult.stdout, "the Resend OAuth token")); + if (!token.success) throw new Error("Vercel returned an invalid Resend OAuth token."); + return { + accessToken: token.data.token, + connectorUid: connector.data.uid, + cleanup: () => + cleanupSetupConnector({ + connectorUid: connector.data.uid, + projectRoot: input.projectRoot, + orgId: input.orgId, + signal: input.signal, + deps, + }), + }; + } catch (error) { + await cleanupSetupConnector({ + connectorUid: connector.data.uid, + projectRoot: input.projectRoot, + orgId: input.orgId, + signal: input.signal, + deps, + }).catch(() => {}); + throw error; + } +} diff --git a/packages/eve/src/setup/integrations/resend/marketplace-webhook.test.ts b/packages/eve/src/setup/integrations/resend/marketplace-webhook.test.ts new file mode 100644 index 0000000000..b04d037359 --- /dev/null +++ b/packages/eve/src/setup/integrations/resend/marketplace-webhook.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + deleteMarketplaceResendWebhooks, + reconcileMarketplaceResendWebhook, +} from "./marketplace-webhook.js"; + +function response(body: unknown, status = 200): Response { + return new Response(status === 204 ? null : JSON.stringify(body), { status }); +} + +describe("Resend Marketplace webhook setup", () => { + it("reconciles an email.received webhook with the temporary OAuth token", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + response({ + data: [ + { id: "wh_old", endpoint: "https://agent.test/eve/v1/resend/" }, + { id: "wh_other", endpoint: "https://other.test/webhook" }, + ], + }), + ) + .mockResolvedValueOnce(response({ id: "wh_new", signing_secret: "whsec_new" })); + + await expect( + reconcileMarketplaceResendWebhook({ + accessToken: "oauth_secret", + endpoint: "https://agent.test/eve/v1/resend", + deps: { fetch }, + }), + ).resolves.toEqual({ + id: "wh_new", + signingSecret: "whsec_new", + previousIds: ["wh_old"], + }); + + expect(fetch.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer oauth_secret" }, + }); + expect(fetch.mock.calls[1]?.[1]?.body).toBe( + JSON.stringify({ + endpoint: "https://agent.test/eve/v1/resend", + events: ["email.received"], + }), + ); + }); + + it("deletes webhooks without putting the OAuth token in the URL", async () => { + const fetch = vi.fn(async () => response(undefined, 204)); + await deleteMarketplaceResendWebhooks({ + accessToken: "oauth_secret", + ids: ["wh_old"], + deps: { fetch }, + }); + expect(fetch.mock.calls[0]?.[0]).toBe("https://api.resend.com/webhooks/wh_old"); + expect(String(fetch.mock.calls[0]?.[0])).not.toContain("oauth_secret"); + }); +}); diff --git a/packages/eve/src/setup/integrations/resend/marketplace-webhook.ts b/packages/eve/src/setup/integrations/resend/marketplace-webhook.ts new file mode 100644 index 0000000000..72935cdb68 --- /dev/null +++ b/packages/eve/src/setup/integrations/resend/marketplace-webhook.ts @@ -0,0 +1,110 @@ +import { z } from "zod"; + +const WebhookSchema = z.object({ + id: z.string().min(1), + endpoint: z.string().url(), +}); +const WebhookListSchema = z.object({ data: z.array(WebhookSchema) }); +const CreatedWebhookSchema = z.union([ + z.object({ id: z.string().min(1), signing_secret: z.string().min(1) }), + z.object({ + data: z.object({ id: z.string().min(1), signing_secret: z.string().min(1) }), + }), +]); + +/** Newly created Resend webhook plus exact-match webhooks it supersedes. */ +export interface MarketplaceWebhookReconciliation { + id: string; + signingSecret: string; + previousIds: string[]; +} + +export interface MarketplaceWebhookDeps { + fetch: typeof fetch; +} + +const defaultDeps: MarketplaceWebhookDeps = { fetch }; + +async function request( + accessToken: string, + path: string, + init: RequestInit, + deps: MarketplaceWebhookDeps, +): Promise { + const headers: Record = { Authorization: `Bearer ${accessToken}` }; + if (init.body !== undefined) headers["Content-Type"] = "application/json"; + let response: Response; + try { + response = await deps.fetch(`https://api.resend.com${path}`, { ...init, headers }); + } catch { + throw new Error("Could not reach Resend while configuring the webhook."); + } + if (!response.ok) { + throw new Error(`Resend webhook request failed with HTTP ${response.status}.`); + } + if (response.status === 204) return undefined; + try { + return (await response.json()) as unknown; + } catch { + throw new Error("Resend returned an invalid webhook response."); + } +} + +function normalizedEndpoint(value: string): string { + const url = new URL(value); + url.hash = ""; + if (url.pathname !== "/") url.pathname = url.pathname.replace(/\/+$/u, ""); + return url.href; +} + +/** Creates a replacement webhook with a temporary Resend OAuth token. */ +export async function reconcileMarketplaceResendWebhook(input: { + accessToken: string; + endpoint: string; + signal?: AbortSignal; + deps?: MarketplaceWebhookDeps; +}): Promise { + const deps = input.deps ?? defaultDeps; + const listed = WebhookListSchema.safeParse( + await request(input.accessToken, "/webhooks", { method: "GET", signal: input.signal }, deps), + ); + if (!listed.success) throw new Error("Resend returned an invalid webhook list."); + const previousIds = listed.data.data + .filter( + (webhook) => normalizedEndpoint(webhook.endpoint) === normalizedEndpoint(input.endpoint), + ) + .map((webhook) => webhook.id); + const createdResult = CreatedWebhookSchema.safeParse( + await request( + input.accessToken, + "/webhooks", + { + method: "POST", + body: JSON.stringify({ endpoint: input.endpoint, events: ["email.received"] }), + signal: input.signal, + }, + deps, + ), + ); + if (!createdResult.success) throw new Error("Resend returned an invalid created webhook."); + const created = "data" in createdResult.data ? createdResult.data.data : createdResult.data; + return { id: created.id, signingSecret: created.signing_secret, previousIds }; +} + +/** Deletes webhooks with a temporary Resend OAuth token. */ +export async function deleteMarketplaceResendWebhooks(input: { + accessToken: string; + ids: readonly string[]; + signal?: AbortSignal; + deps?: MarketplaceWebhookDeps; +}): Promise { + const deps = input.deps ?? defaultDeps; + for (const id of input.ids) { + await request( + input.accessToken, + `/webhooks/${encodeURIComponent(id)}`, + { method: "DELETE", signal: input.signal }, + deps, + ); + } +} diff --git a/packages/eve/src/setup/integrations/resend/marketplace.test.ts b/packages/eve/src/setup/integrations/resend/marketplace.test.ts new file mode 100644 index 0000000000..14487a6371 --- /dev/null +++ b/packages/eve/src/setup/integrations/resend/marketplace.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createFakePrompter } from "#internal/testing/fake-prompter.js"; +import { + connectResendMarketplaceResource, + listResendMarketplaceResources, + listVercelDomains, + inspectResendMarketplaceResource, + provisionResendMarketplaceResource, + waitForResendMarketplaceDomain, + type ResendMarketplaceDeps, +} from "./marketplace.js"; + +function capture(stdout: unknown): ResendMarketplaceDeps["captureVercel"] { + return vi.fn(async () => ({ + ok: true, + stdout: JSON.stringify(stdout), + })); +} + +function deps(input: { + captureVercel?: ResendMarketplaceDeps["captureVercel"]; + runVercelCaptureStdout?: ResendMarketplaceDeps["runVercelCaptureStdout"]; + delay?: ResendMarketplaceDeps["delay"]; +}): ResendMarketplaceDeps { + return { + captureVercel: input.captureVercel ?? capture({ stores: [] }), + runVercelCaptureStdout: + input.runVercelCaptureStdout ?? vi.fn(async () => ({ ok: false, stdout: "" })), + delay: input.delay ?? vi.fn(async () => {}), + }; +} + +describe("Resend Marketplace", () => { + it("lists only Resend Marketplace resources", async () => { + const captureVercel = capture({ + stores: [ + { + id: "store_resend", + externalResourceId: "example.com", + name: "resend-agent", + product: { slug: "resend-email", integrationConfigurationId: "icfg_resend" }, + }, + { + id: "store_other", + externalResourceId: "db-1", + name: "database", + product: { slug: "postgres" }, + }, + { + id: "store_blob", + name: "blob-without-external-id", + type: "blob", + }, + ], + }); + + await expect( + listResendMarketplaceResources({ + projectRoot: "/project", + project: { orgId: "team", projectId: "project" }, + deps: { captureVercel }, + }), + ).resolves.toEqual([ + expect.objectContaining({ id: "store_resend", externalResourceId: "example.com" }), + ]); + }); + + it("prioritizes useful production aliases before Vercel-owned domains", async () => { + const captureVercel = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + stdout: JSON.stringify({ + domains: [{ name: "example.com" }, { name: "another.example" }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + stdout: JSON.stringify({ + targets: { + production: { + alias: [ + "resend-eve-test.playground-vercel.tools", + "resend-eve-test.vercel.app", + "resend-eve-test-git-main.preview.example.com", + ], + }, + }, + }), + }); + + await expect( + listVercelDomains({ + projectRoot: "/project", + project: { orgId: "team", projectId: "project" }, + deps: { captureVercel }, + }), + ).resolves.toEqual([ + "resend-eve-test.playground-vercel.tools", + "example.com", + "another.example", + ]); + expect(captureVercel).toHaveBeenCalledWith( + ["domains", "list", "--format", "json", "--limit", "100", "--scope", "team"], + expect.objectContaining({ cwd: "/project" }), + ); + }); + + it("provisions Resend with domain metadata and production connection", async () => { + const runVercelCaptureStdout = vi.fn(async () => ({ + ok: true, + stdout: JSON.stringify({ + resource: { + id: "store_resend", + name: "resend-agent", + externalResourceId: "example.com", + }, + installation: { id: "icfg_resend" }, + }), + })); + + await provisionResendMarketplaceResource({ + domain: "example.com", + log: createFakePrompter().prompter.log, + projectRoot: "/project", + project: { orgId: "team", projectId: "project" }, + deps: deps({ runVercelCaptureStdout }), + }); + + expect(runVercelCaptureStdout).toHaveBeenCalledWith( + [ + "integration", + "add", + "resend", + "--metadata", + "domain=example.com", + "--metadata", + "region=us-east-1", + "--environment", + "production", + "--format", + "json", + "--scope", + "team", + ], + expect.objectContaining({ cwd: "/project" }), + ); + }); + + it("polls for the resource after Marketplace hands setup to the browser", async () => { + const runVercelCaptureStdout = vi.fn(async () => ({ ok: false, stdout: "" })); + const captureVercel = vi + .fn() + .mockResolvedValueOnce({ ok: true, stdout: JSON.stringify({ stores: [] }) }) + .mockResolvedValueOnce({ + ok: true, + stdout: JSON.stringify({ + stores: [ + { + id: "store_resend", + externalResourceId: "provider-id", + name: "resend-agent", + metadata: { domain: "mail.example.com" }, + product: { slug: "resend-email" }, + }, + ], + }), + }); + const delay = vi.fn(async () => {}); + const log = createFakePrompter().prompter.log; + + await expect( + provisionResendMarketplaceResource({ + domain: "mail.example.com", + log, + projectRoot: "/project", + project: { orgId: "team", projectId: "project" }, + deps: deps({ captureVercel, runVercelCaptureStdout, delay }), + pollIntervalMs: 1, + pollTimeoutMs: 1_000, + }), + ).resolves.toMatchObject({ id: "store_resend" }); + expect(delay).toHaveBeenCalledOnce(); + expect(log.info).toHaveBeenCalledWith(expect.stringContaining("safely stop waiting")); + }); + + it("reads live provider status with integration resource inspect", async () => { + const captureVercel = capture({ + resource: { id: "store_resend", name: "resend-agent", status: "available" }, + }); + await expect( + inspectResendMarketplaceResource({ + resource: { + id: "store_resend", + externalResourceId: "provider-id", + name: "resend-agent", + status: "onboarding", + }, + projectRoot: "/project", + project: { orgId: "team", projectId: "project" }, + deps: { captureVercel }, + }), + ).resolves.toMatchObject({ status: "available" }); + expect(captureVercel).toHaveBeenCalledWith( + ["integration", "resource", "inspect", "resend-agent", "--format", "json", "--scope", "team"], + expect.objectContaining({ cwd: "/project" }), + ); + }); + + it("tracks DNS verification until the live Marketplace resource becomes ready", async () => { + const captureVercel = capture({ + resource: { id: "store_resend", name: "resend-agent", status: "available" }, + }); + const delay = vi.fn(async () => {}); + const log = createFakePrompter().prompter.log; + + await expect( + waitForResendMarketplaceDomain({ + resource: { + id: "store_resend", + externalResourceId: "provider-id", + name: "resend-agent", + status: "onboarding", + externalResourceStatus: "onboarding", + metadata: { domain: "mail.example.com" }, + product: { slug: "resend-email" }, + }, + domain: "mail.example.com", + log, + projectRoot: "/project", + project: { orgId: "team", projectId: "project" }, + deps: { captureVercel, delay }, + pollIntervalMs: 1, + pollTimeoutMs: 1_000, + }), + ).resolves.toMatchObject({ status: "available" }); + expect(delay).toHaveBeenCalledOnce(); + expect(log.info).toHaveBeenCalledWith(expect.stringContaining("configuring DNS")); + expect(log.info).toHaveBeenCalledWith(expect.stringContaining("safely stop waiting")); + }); + + it("connects an existing resource using the Vercel CLI JSON format flag", async () => { + const runVercelCaptureStdout = vi.fn(async () => ({ + ok: true, + stdout: JSON.stringify({ connected: true }), + })); + await connectResendMarketplaceResource({ + resource: { + id: "store_resend", + externalResourceId: "provider-id", + name: "resend-agent", + }, + log: createFakePrompter().prompter.log, + projectRoot: "/project", + project: { orgId: "team", projectId: "project" }, + deps: { runVercelCaptureStdout }, + }); + expect(runVercelCaptureStdout).toHaveBeenCalledWith( + [ + "integration", + "resource", + "connect", + "resend-agent", + "--environment", + "production", + "--yes", + "--format", + "json", + "--scope", + "team", + ], + expect.objectContaining({ cwd: "/project", nonInteractive: true }), + ); + }); + + it("does not reconnect a resource already attached to the linked project", async () => { + const runVercelCaptureStdout = vi.fn(); + await connectResendMarketplaceResource({ + resource: { + id: "store_resend", + externalResourceId: "example.com", + name: "resend-agent", + projectsMetadata: [{ projectId: "project", environments: ["production"] }], + }, + log: createFakePrompter().prompter.log, + projectRoot: "/project", + project: { orgId: "team", projectId: "project" }, + deps: { runVercelCaptureStdout }, + }); + expect(runVercelCaptureStdout).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/setup/integrations/resend/marketplace.ts b/packages/eve/src/setup/integrations/resend/marketplace.ts new file mode 100644 index 0000000000..9def447214 --- /dev/null +++ b/packages/eve/src/setup/integrations/resend/marketplace.ts @@ -0,0 +1,375 @@ +import { setTimeout as delay } from "node:timers/promises"; + +import { createPromptCommandOutput, type ChannelSetupLog, withPhase } from "#setup/cli/index.js"; +import type { VercelProjectReference } from "#setup/project-resolution.js"; +import { captureVercel, runVercelCaptureStdout } from "#setup/primitives/run-vercel.js"; +import { z } from "zod"; + +const ResourceSchema = z.object({ + id: z.string().min(1), + externalResourceId: z.string().min(1).optional(), + name: z.string().min(1), + status: z.string().nullish(), + externalResourceStatus: z.string().nullish(), + metadata: z.object({ domain: z.string().min(1).optional() }).optional(), + product: z + .object({ + slug: z.string().optional(), + integrationConfigurationId: z.string().optional(), + integration: z.object({ slug: z.string().optional() }).optional(), + }) + .optional(), + projectsMetadata: z + .array( + z.object({ + projectId: z.string(), + environments: z.array(z.string()).optional(), + }), + ) + .optional(), +}); +const ResourceListSchema = z.object({ stores: z.array(z.unknown()) }); +const DomainListSchema = z.object({ domains: z.array(z.object({ name: z.string().min(1) })) }); +const ProjectSchema = z.object({ + targets: z + .object({ + production: z.object({ alias: z.array(z.string()).optional() }).optional(), + }) + .optional(), +}); +const InspectedResourceSchema = z.object({ + resource: z.object({ + id: z.string().min(1), + name: z.string().min(1), + status: z.string().min(1), + }), +}); +const ProvisionedSchema = z.object({ + resource: z.object({ + id: z.string().min(1), + name: z.string().min(1), + externalResourceId: z.string().min(1), + }), + installation: z.object({ id: z.string().min(1) }), + dashboardUrl: z.string().url().optional(), +}); + +/** Resend Marketplace resource visible to the current Vercel team. */ +export type ResendMarketplaceResource = z.infer; + +export interface ResendMarketplaceDeps { + captureVercel: typeof captureVercel; + runVercelCaptureStdout: typeof runVercelCaptureStdout; + delay(ms: number, signal?: AbortSignal): Promise; +} + +const defaultDeps: ResendMarketplaceDeps = { + captureVercel, + runVercelCaptureStdout, + delay: (ms, signal) => delay(ms, undefined, { signal }), +}; + +const MARKETPLACE_POLL_INTERVAL_MS = 3_000; +const MARKETPLACE_POLL_TIMEOUT_MS = 10 * 60_000; +const DOMAIN_READY_POLL_TIMEOUT_MS = 15 * 60_000; +const READY_RESOURCE_STATUSES = new Set(["active", "available", "ready"]); + +/** Lists existing Resend Marketplace resources without reading their secrets. */ +export async function listResendMarketplaceResources(input: { + projectRoot: string; + project: VercelProjectReference; + signal?: AbortSignal; + deps?: Pick; +}): Promise { + const deps = input.deps ?? defaultDeps; + const result = await deps.captureVercel( + ["api", "/v1/storage/stores", "--scope", input.project.orgId], + { cwd: input.projectRoot, signal: input.signal }, + ); + if (!result.ok) throw new Error("Could not inspect Vercel Marketplace resources."); + let body: unknown; + try { + body = JSON.parse(result.stdout) as unknown; + } catch { + throw new Error("Vercel returned invalid JSON for Marketplace resources."); + } + const parsed = ResourceListSchema.safeParse(body); + if (!parsed.success) throw new Error("Vercel returned an invalid Marketplace resource list."); + const resources: ResendMarketplaceResource[] = []; + for (const candidate of parsed.data.stores) { + const resource = ResourceSchema.safeParse(candidate); + if (!resource.success) continue; + if ( + resource.data.product?.slug === "resend-email" || + resource.data.product?.integration?.slug === "resend" + ) { + resources.push(resource.data); + } + } + return resources; +} + +/** Lists project production aliases and domains owned by the linked Vercel team. */ +export async function listVercelDomains(input: { + projectRoot: string; + project: VercelProjectReference; + signal?: AbortSignal; + deps?: Pick; +}): Promise { + const deps = input.deps ?? defaultDeps; + const [domainsResult, projectResult] = await Promise.all([ + deps.captureVercel( + ["domains", "list", "--format", "json", "--limit", "100", "--scope", input.project.orgId], + { cwd: input.projectRoot, signal: input.signal }, + ), + deps.captureVercel( + [ + "api", + `/v9/projects/${input.project.projectId}?teamId=${input.project.orgId}`, + "--scope", + input.project.orgId, + ], + { cwd: input.projectRoot, signal: input.signal }, + ), + ]); + if (!domainsResult.ok) throw new Error("Could not inspect Vercel domains."); + let domainsBody: unknown; + let projectBody: unknown; + try { + domainsBody = JSON.parse(domainsResult.stdout) as unknown; + projectBody = projectResult.ok ? (JSON.parse(projectResult.stdout) as unknown) : undefined; + } catch { + throw new Error("Vercel returned invalid JSON for the domain list."); + } + const domains = DomainListSchema.safeParse(domainsBody); + if (!domains.success) throw new Error("Vercel returned an invalid domain list."); + const project = ProjectSchema.safeParse(projectBody); + const aliases = project.success ? (project.data.targets?.production?.alias ?? []) : []; + const usefulAliases = aliases.filter( + (alias) => + !alias.endsWith(".vercel.app") && !alias.includes("-git-") && !alias.includes(".preview."), + ); + return [...new Set([...usefulAliases, ...domains.data.domains.map((domain) => domain.name)])]; +} + +/** Provisions and connects a Resend Marketplace resource through Vercel CLI. */ +export async function provisionResendMarketplaceResource(input: { + domain: string; + log: ChannelSetupLog; + projectRoot: string; + project: VercelProjectReference; + signal?: AbortSignal; + deps?: ResendMarketplaceDeps; + pollIntervalMs?: number; + pollTimeoutMs?: number; +}): Promise { + const deps = input.deps ?? defaultDeps; + const result = await withPhase(input.log, "Setting up Resend in Vercel Marketplace...", () => + deps.runVercelCaptureStdout( + [ + "integration", + "add", + "resend", + "--metadata", + `domain=${input.domain}`, + "--metadata", + "region=us-east-1", + "--environment", + "production", + "--format", + "json", + "--scope", + input.project.orgId, + ], + { + cwd: input.projectRoot, + onOutput: createPromptCommandOutput(input.log), + signal: input.signal, + }, + ), + ); + if (!result.ok) { + input.log.info( + `Complete Resend setup in the browser for ${input.domain}. This can take several minutes.`, + ); + input.log.info( + "You can safely stop waiting and rerun `eve add channel/resend`; setup will reuse the new Marketplace resource.", + ); + const deadline = Date.now() + (input.pollTimeoutMs ?? MARKETPLACE_POLL_TIMEOUT_MS); + const pollIntervalMs = input.pollIntervalMs ?? MARKETPLACE_POLL_INTERVAL_MS; + return withPhase( + input.log, + "Waiting for Resend Marketplace setup in the browser...", + async () => { + while (Date.now() < deadline) { + input.signal?.throwIfAborted(); + const resources = await listResendMarketplaceResources({ + projectRoot: input.projectRoot, + project: input.project, + signal: input.signal, + deps, + }); + const resource = resources.find( + (candidate) => candidate.metadata?.domain?.toLowerCase() === input.domain.toLowerCase(), + ); + if (resource !== undefined) return resource; + await deps.delay(pollIntervalMs, input.signal); + } + throw new Error( + `Resend Marketplace setup is still pending for ${input.domain}. Finish it in the browser, then rerun \`eve add channel/resend\`; the existing resource will be reused.`, + ); + }, + { kind: "external-action", emphasis: "browser" }, + ); + } + let body: unknown; + try { + body = JSON.parse(result.stdout) as unknown; + } catch { + throw new Error("Vercel returned invalid JSON after Resend Marketplace setup."); + } + const parsed = ProvisionedSchema.safeParse(body); + if (!parsed.success) throw new Error("Vercel returned an invalid Resend Marketplace result."); + return { + id: parsed.data.resource.id, + externalResourceId: parsed.data.resource.externalResourceId, + metadata: { domain: input.domain }, + name: parsed.data.resource.name, + product: { + slug: "resend-email", + integrationConfigurationId: parsed.data.installation.id, + }, + projectsMetadata: [{ projectId: input.project.projectId, environments: ["production"] }], + }; +} + +/** Reads live provider-backed status instead of the eventually consistent store summary. */ +export async function inspectResendMarketplaceResource(input: { + resource: ResendMarketplaceResource; + projectRoot: string; + project: VercelProjectReference; + signal?: AbortSignal; + deps?: Pick; +}): Promise { + const deps = input.deps ?? defaultDeps; + const result = await deps.captureVercel( + [ + "integration", + "resource", + "inspect", + input.resource.name, + "--format", + "json", + "--scope", + input.project.orgId, + ], + { cwd: input.projectRoot, signal: input.signal }, + ); + if (!result.ok) return undefined; + let body: unknown; + try { + body = JSON.parse(result.stdout) as unknown; + } catch { + return undefined; + } + const parsed = InspectedResourceSchema.safeParse(body); + return parsed.success + ? { ...input.resource, id: parsed.data.resource.id, status: parsed.data.resource.status } + : undefined; +} + +/** Whether Resend reports its Marketplace resource ready. */ +export function isResendMarketplaceResourceReady(resource: ResendMarketplaceResource): boolean { + return READY_RESOURCE_STATUSES.has(resource.status ?? ""); +} + +/** Waits for Resend and its DNS verification to become ready, supporting safe reruns on timeout. */ +export async function waitForResendMarketplaceDomain(input: { + resource: ResendMarketplaceResource; + domain: string; + log: ChannelSetupLog; + projectRoot: string; + project: VercelProjectReference; + signal?: AbortSignal; + deps?: Pick; + pollIntervalMs?: number; + pollTimeoutMs?: number; +}): Promise { + if (isResendMarketplaceResourceReady(input.resource)) return input.resource; + const deps = input.deps ?? defaultDeps; + const deadline = Date.now() + (input.pollTimeoutMs ?? DOMAIN_READY_POLL_TIMEOUT_MS); + const pollIntervalMs = input.pollIntervalMs ?? MARKETPLACE_POLL_INTERVAL_MS; + input.log.info( + `Resend is configuring DNS for ${input.domain}. Verification can take several minutes.`, + ); + input.log.info( + "You can safely stop waiting and rerun `eve add channel/resend`; setup will resume from this resource.", + ); + return withPhase( + input.log, + `Waiting for Resend domain DNS (${input.domain})...`, + async () => { + while (Date.now() < deadline) { + input.signal?.throwIfAborted(); + await deps.delay(pollIntervalMs, input.signal); + const current = await inspectResendMarketplaceResource({ + resource: input.resource, + projectRoot: input.projectRoot, + project: input.project, + signal: input.signal, + deps, + }); + if (current !== undefined && isResendMarketplaceResourceReady(current)) return current; + } + throw new Error( + `Resend is still verifying DNS for ${input.domain}. Finish any requested DNS setup in Resend, then rerun \`eve add channel/resend\`; setup will reuse this resource.`, + ); + }, + { kind: "external-action", emphasis: "browser" }, + ); +} + +/** Connects an existing Marketplace resource to the linked project for production. */ +export async function connectResendMarketplaceResource(input: { + resource: ResendMarketplaceResource; + log: ChannelSetupLog; + projectRoot: string; + project: VercelProjectReference; + signal?: AbortSignal; + deps?: Pick; +}): Promise { + if ( + input.resource.projectsMetadata?.some((entry) => entry.projectId === input.project.projectId) + ) { + return; + } + const deps = input.deps ?? defaultDeps; + const result = await withPhase(input.log, "Connecting Resend to this project...", () => + deps.runVercelCaptureStdout( + [ + "integration", + "resource", + "connect", + input.resource.name, + "--environment", + "production", + "--yes", + "--format", + "json", + "--scope", + input.project.orgId, + ], + { + cwd: input.projectRoot, + nonInteractive: true, + onOutput: createPromptCommandOutput(input.log), + signal: input.signal, + }, + ), + ); + if (!result.ok) { + throw new Error( + `Could not connect Marketplace resource ${input.resource.name}. Run \`vercel integration resource connect ${input.resource.name} --environment production --yes\`.`, + ); + } +} diff --git a/packages/eve/src/setup/integrations/resend/setup.test.ts b/packages/eve/src/setup/integrations/resend/setup.test.ts index 36b9e313e6..47d130dcd2 100644 --- a/packages/eve/src/setup/integrations/resend/setup.test.ts +++ b/packages/eve/src/setup/integrations/resend/setup.test.ts @@ -30,10 +30,31 @@ function deps(): ResendSetupDeps { deriveConnectorSlug: vi.fn(async () => "agent" as never), ensureVercelProject: vi.fn(async () => ({ orgId: "team", projectId: "project" })), listWebhooks: vi.fn(async () => []), - provisionConnector: vi.fn(async () => ({ - id: "scl_resend", - uid: "api-key/resend-agent", + listMarketplaceResources: vi.fn(async () => []), + listDomains: vi.fn(async () => ["example.com"]), + openUrl: vi.fn(), + provisionMarketplaceResource: vi.fn(async (input) => ({ + id: "store_resend", + externalResourceId: input.domain, + name: "resend-agent", + product: { slug: "resend-email", integrationConfigurationId: "icfg_resend" }, + projectsMetadata: [{ projectId: "project", environments: ["production"] }], })), + connectMarketplaceResource: vi.fn(async () => {}), + authorizeMarketplaceSetup: vi.fn(async () => ({ + accessToken: "oauth_marketplace", + connectorUid: "oauth/eve-resend-setup", + cleanup: vi.fn(async () => {}), + })), + createApiKey: vi.fn(async () => ({ id: "key_resend", token: "re_generated" })), + deleteApiKey: vi.fn(async () => {}), + reconcileMarketplaceWebhook: vi.fn(async () => ({ + id: "wh_marketplace", + signingSecret: "whsec_marketplace", + previousIds: [], + })), + deleteMarketplaceWebhooks: vi.fn(async () => {}), + waitForMarketplaceDomain: vi.fn(async (input) => input.resource), runVercel: vi.fn(async () => true), suggestFromAddress: vi.fn(async () => "eve@example.com"), validateApiKey: vi.fn(async () => {}), @@ -41,8 +62,16 @@ function deps(): ResendSetupDeps { }; } -function context(effects: ResendSetupDeps, select: "connect" | "portable" = "connect") { - const fake = createFakePrompter({ single: () => select }); +function context( + effects: ResendSetupDeps, + select: "marketplace" | "connect" | "portable" = "connect", +) { + const fake = createFakePrompter({ + single: (options) => + options.message === "How would you like to configure Resend?" + ? select + : (options.initialValue ?? options.options[0]!.value), + }); return { effects, value: { @@ -61,6 +90,158 @@ function context(effects: ResendSetupDeps, select: "connect" | "portable" = "con } describe("Resend setup", () => { + it("provisions a Marketplace resource from an existing Vercel domain", async () => { + const effects = deps(); + const setup = context(effects, "marketplace"); + + await expect(setupResend(setup.value, effects)).resolves.toMatchObject({ kind: "done" }); + expect(effects.provisionMarketplaceResource).toHaveBeenCalledWith( + expect.objectContaining({ domain: "example.com" }), + ); + expect(effects.connectMarketplaceResource).toHaveBeenCalledWith( + expect.objectContaining({ resource: expect.objectContaining({ id: "store_resend" }) }), + ); + expect(effects.waitForMarketplaceDomain).toHaveBeenCalledWith( + expect.objectContaining({ domain: "example.com" }), + ); + expect(effects.writeTextFile).toHaveBeenCalledWith( + "/project/agent/channels/resend.ts", + expect.stringContaining("process.env.RESEND_API_KEY"), + { force: undefined }, + ); + expect(effects.authorizeMarketplaceSetup).toHaveBeenCalledWith( + expect.objectContaining({ orgId: "team" }), + ); + expect(effects.reconcileMarketplaceWebhook).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: "oauth_marketplace", + endpoint: "https://agent.test/eve/v1/resend", + }), + ); + expect(effects.runVercel).toHaveBeenCalledWith( + ["env", "add", "RESEND_WEBHOOK_SECRET", "production", "--force", "--yes"], + expect.objectContaining({ stdin: "whsec_marketplace" }), + ); + }); + + it("reuses an existing Marketplace resource", async () => { + const effects = deps(); + vi.mocked(effects.listMarketplaceResources).mockResolvedValue([ + { + id: "store_existing", + externalResourceId: "mail.example.com", + name: "resend-existing", + product: { slug: "resend-email", integrationConfigurationId: "icfg_existing" }, + projectsMetadata: [{ projectId: "project", environments: ["production"] }], + }, + ]); + const setup = context(effects, "marketplace"); + + await expect(setupResend(setup.value, effects)).resolves.toMatchObject({ kind: "done" }); + expect(effects.provisionMarketplaceResource).not.toHaveBeenCalled(); + expect(effects.connectMarketplaceResource).toHaveBeenCalledWith( + expect.objectContaining({ resource: expect.objectContaining({ id: "store_existing" }) }), + ); + }); + + it("offers a searchable domain picker and Vercel web handoff", async () => { + const effects = deps(); + vi.mocked(effects.listDomains).mockResolvedValue([ + "alpha.example", + "beta.example", + "gamma.example", + "delta.example", + "epsilon.example", + "zeta.example", + ]); + let domainPicker: unknown; + const fake = createFakePrompter({ + single: (options) => { + if (options.message === "How would you like to configure Resend?") return "marketplace"; + domainPicker = options; + return "__add-vercel-domain__"; + }, + }); + const setup = context(effects, "marketplace"); + const value = { + ...setup.value, + ui: createIntegrationSetupUi({ asker: setup.value.ui.asker, prompter: fake.prompter }), + }; + + await expect(setupResend(value, effects)).resolves.toEqual({ kind: "cancelled" }); + expect(domainPicker).toEqual( + expect.objectContaining({ + message: "Domain for Resend", + search: true, + placeholder: "type to filter domains", + options: [ + expect.objectContaining({ + value: "alpha.example", + featured: true, + hint: "Current production domain · recommended", + }), + expect.objectContaining({ + value: "__add-vercel-domain__", + featured: true, + trailingAction: true, + }), + expect.objectContaining({ value: "beta.example", featured: true }), + expect.objectContaining({ value: "gamma.example", featured: true }), + expect.objectContaining({ value: "delta.example", featured: true }), + expect.objectContaining({ value: "epsilon.example", featured: false }), + expect.objectContaining({ value: "zeta.example", featured: false }), + ], + }), + ); + expect(effects.openUrl).toHaveBeenCalledWith("https://vercel.com/domains"); + }); + + it("hands domain setup off to Vercel web when the team has no domain", async () => { + const effects = deps(); + vi.mocked(effects.listDomains).mockResolvedValue([]); + const setup = context(effects, "marketplace"); + + await expect(setupResend(setup.value, effects)).resolves.toEqual({ kind: "cancelled" }); + expect(effects.openUrl).toHaveBeenCalledWith("https://vercel.com/domains"); + expect(setup.value.ui.prompter.note).toHaveBeenCalledWith( + expect.stringContaining("rerun `eve add channel/resend`"), + "Vercel domain required", + { tone: "warning" }, + ); + expect(effects.provisionMarketplaceResource).not.toHaveBeenCalled(); + }); + + it("defaults the optional sender name to Eve", async () => { + const effects = deps(); + const questions: Question[] = []; + const setup = context(effects, "portable"); + const value = { + ...setup.value, + ui: { + ...setup.value.ui, + asker: { + ask: async (question: Question) => { + questions.push(question as Question); + if (question.key === "resend-api-key") return "re_secret" as T; + if (question.key === "resend-from-address") return "eve@example.com" as T; + return question.detected as T; + }, + askMany: async () => [], + }, + }, + }; + + await expect(setupResend(value, effects)).resolves.toEqual({ kind: "done" }); + expect(questions).toContainEqual( + expect.objectContaining({ key: "resend-from-name", detected: "Eve" }), + ); + expect(effects.writeTextFile).toHaveBeenCalledWith( + "/project/agent/channels/resend.ts", + expect.stringContaining('fromName: "Eve"'), + { force: undefined }, + ); + }); + it("prefills the agent address from a send-and-receive custom Resend domain", async () => { const effects = deps(); const questions: Question[] = []; @@ -138,16 +319,43 @@ describe("Resend setup", () => { ); }); + it("uses existing-account OAuth to create the runtime API key without prompting", async () => { + const effects = deps(); + const questions: Question[] = []; + const setup = context(effects, "connect"); + const value = { + ...setup.value, + ui: { + ...setup.value.ui, + asker: { + ask: async (question: Question) => { + questions.push(question as Question); + if (question.key === "resend-from-name") return "Eve" as T; + return question.detected as T; + }, + askMany: async () => [], + }, + }, + }; + + await expect(setupResend(value, effects)).resolves.toMatchObject({ kind: "done" }); + expect(questions.map((question) => question.key)).not.toContain("resend-api-key"); + expect(effects.createApiKey).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: "oauth_marketplace" }), + ); + expect(effects.validateApiKey).toHaveBeenCalledWith("re_generated", undefined); + expect(effects.runVercel).toHaveBeenCalledWith( + ["env", "add", "RESEND_API_KEY", "production", "--force", "--yes"], + expect.objectContaining({ stdin: "re_generated" }), + ); + }); + it("uses one normalized key and orders deploy, webhook, env, and redeploy", async () => { const effects = deps(); const events: string[] = []; vi.mocked(effects.validateApiKey).mockImplementation(async (key) => { events.push(`validate:${key}`); }); - vi.mocked(effects.provisionConnector).mockImplementation(async (input) => { - events.push(`connector:${input.apiKey}`); - return { id: "scl_resend", uid: "api-key/resend-agent" }; - }); vi.mocked(effects.deploy).mockReset(); vi.mocked(effects.deploy).mockImplementation(async () => { events.push("deploy"); @@ -166,11 +374,12 @@ describe("Resend setup", () => { return true; }); + vi.mocked(effects.createApiKey).mockResolvedValue({ id: "key_resend", token: "re_secret" }); const setup = context(effects); await expect(setupResend(setup.value, effects)).resolves.toMatchObject({ kind: "done" }); expect(events).toEqual([ "validate:re_secret", - "connector:re_secret", + "env:re_secret", "deploy", "webhook:re_secret", "env:whsec_secret", @@ -178,14 +387,15 @@ describe("Resend setup", () => { ]); expect(effects.writeTextFile).toHaveBeenCalledWith( "/project/agent/channels/resend.ts", - expect.stringContaining('connectResendApiKey("api-key/resend-agent")'), + expect.stringContaining("process.env.RESEND_API_KEY"), { force: undefined }, ); }); it("compensates a newly created webhook when saving the secret fails", async () => { const effects = deps(); - vi.mocked(effects.runVercel).mockResolvedValue(false); + vi.mocked(effects.runVercel).mockResolvedValueOnce(true).mockResolvedValueOnce(false); + vi.mocked(effects.createApiKey).mockResolvedValue({ id: "key_resend", token: "re_secret" }); const setup = context(effects); await expect(setupResend(setup.value, effects)).rejects.toThrow("may persist"); expect(effects.deleteWebhook).toHaveBeenCalledWith("re_secret", "wh_new", undefined); @@ -198,7 +408,6 @@ describe("Resend setup", () => { expect(effects.appendEnv).toHaveBeenCalledWith("/project/.env.local", { RESEND_API_KEY: "re_secret", }); - expect(effects.provisionConnector).not.toHaveBeenCalled(); expect(effects.writeTextFile).toHaveBeenCalledWith( "/project/agent/channels/resend.ts", expect.stringContaining("process.env.RESEND_API_KEY"), diff --git a/packages/eve/src/setup/integrations/resend/setup.ts b/packages/eve/src/setup/integrations/resend/setup.ts index 498c94c7e1..59d0cff852 100644 --- a/packages/eve/src/setup/integrations/resend/setup.ts +++ b/packages/eve/src/setup/integrations/resend/setup.ts @@ -6,8 +6,10 @@ import { ensureVercelProject } from "#setup/flows/ensure-vercel-project.js"; import { runDeployFlow } from "#setup/flows/deploy.js"; import { deriveSlackConnectorSlug } from "#setup/scaffold/index.js"; import { writeTextFile } from "#setup/scaffold/files.js"; +import { openUrl } from "#setup/primitives/open-url.js"; import { runVercel } from "#setup/primitives/run-vercel.js"; import { WizardCancelledError } from "#setup/step.js"; +import { withSpinner } from "#setup/with-spinner.js"; import type { IntegrationSetupContext, @@ -22,7 +24,23 @@ import { suggestResendFromAddress, validateResendApiKey, } from "./api.js"; -import { provisionResendConnector } from "./connect.js"; +import { + authorizeResendMarketplaceSetup, + createResendApiKey, + deleteResendApiKey, +} from "./marketplace-oauth.js"; +import { + deleteMarketplaceResendWebhooks, + reconcileMarketplaceResendWebhook, +} from "./marketplace-webhook.js"; +import { + connectResendMarketplaceResource, + listResendMarketplaceResources, + listVercelDomains, + provisionResendMarketplaceResource, + waitForResendMarketplaceDomain, + type ResendMarketplaceResource, +} from "./marketplace.js"; export interface ResendSetupDeps { appendEnv: typeof appendEnv; @@ -32,7 +50,17 @@ export interface ResendSetupDeps { deriveConnectorSlug: typeof deriveSlackConnectorSlug; ensureVercelProject: typeof ensureVercelProject; listWebhooks: typeof listResendWebhooks; - provisionConnector: typeof provisionResendConnector; + listMarketplaceResources: typeof listResendMarketplaceResources; + listDomains: typeof listVercelDomains; + openUrl: typeof openUrl; + provisionMarketplaceResource: typeof provisionResendMarketplaceResource; + connectMarketplaceResource: typeof connectResendMarketplaceResource; + authorizeMarketplaceSetup: typeof authorizeResendMarketplaceSetup; + createApiKey: typeof createResendApiKey; + deleteApiKey: typeof deleteResendApiKey; + reconcileMarketplaceWebhook: typeof reconcileMarketplaceResendWebhook; + deleteMarketplaceWebhooks: typeof deleteMarketplaceResendWebhooks; + waitForMarketplaceDomain: typeof waitForResendMarketplaceDomain; runVercel: typeof runVercel; suggestFromAddress: typeof suggestResendFromAddress; validateApiKey: typeof validateResendApiKey; @@ -47,7 +75,17 @@ const defaultDeps: ResendSetupDeps = { deriveConnectorSlug: deriveSlackConnectorSlug, ensureVercelProject, listWebhooks: listResendWebhooks, - provisionConnector: provisionResendConnector, + listMarketplaceResources: listResendMarketplaceResources, + listDomains: listVercelDomains, + openUrl, + provisionMarketplaceResource: provisionResendMarketplaceResource, + connectMarketplaceResource: connectResendMarketplaceResource, + authorizeMarketplaceSetup: authorizeResendMarketplaceSetup, + createApiKey: createResendApiKey, + deleteApiKey: deleteResendApiKey, + reconcileMarketplaceWebhook: reconcileMarketplaceResendWebhook, + deleteMarketplaceWebhooks: deleteMarketplaceResendWebhooks, + waitForMarketplaceDomain: waitForResendMarketplaceDomain, runVercel, suggestFromAddress: suggestResendFromAddress, validateApiKey: validateResendApiKey, @@ -58,35 +96,30 @@ function validateEmail(value: string): string | null { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()) ? null : "Enter a complete email address."; } -function channelTemplate(input: { - apiKey: string; - connectorUid?: string; - fromAddress: string; - fromName: string; -}): string { - const apiKey = input.connectorUid - ? `connectResendApiKey(${JSON.stringify(input.connectorUid)})` - : `() => { - const apiKey = process.env.RESEND_API_KEY; - if (!apiKey) throw new Error("RESEND_API_KEY is required."); - return Promise.resolve(apiKey); - }`; +function channelTemplate(input: { fromAddress: string; fromName: string }): string { return `import { createMemoryState } from "@chat-adapter/state-memory"; import { createResendAdapter } from "@resend/chat-sdk-adapter"; -${input.connectorUid ? 'import { connectResendApiKey } from "@vercel/connect/eve";\n' : ""}import type { Message, Thread } from "chat"; +import type { Message, Thread } from "chat"; import { chatSdkChannel, messageToUserContent } from "eve/channels/chat-sdk"; +import { captureResendReplyContext, restoreResendReplyContext } from "eve/channels/resend"; export const { bot, channel, send } = chatSdkChannel({ - userName: ${JSON.stringify(input.fromName || "Email Agent")}, + userName: ${JSON.stringify(input.fromName || "Eve")}, adapters: { resend: createResendAdapter({ - apiKey: ${apiKey}, + apiKey: () => { + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey) throw new Error("RESEND_API_KEY is required."); + return Promise.resolve(apiKey); + }, fromAddress: ${JSON.stringify(input.fromAddress)}, - fromName: ${JSON.stringify(input.fromName || "Email Agent")}, + fromName: ${JSON.stringify(input.fromName || "Eve")}, }), }, state: createMemoryState(), streaming: false, + captureAdapterContext: captureResendReplyContext, + restoreAdapterContext: restoreResendReplyContext, }); bot.onNewMention(async (thread: Thread, message: Message) => { @@ -104,24 +137,262 @@ export default channel; async function chooseDestination( context: IntegrationSetupContext, -): Promise<"connect" | "portable"> { - if (context.yes) return "connect"; - return context.ui.prompter.select<"connect" | "portable">({ +): Promise<"marketplace" | "connect" | "portable"> { + if (context.yes) return "marketplace"; + return context.ui.prompter.select<"marketplace" | "connect" | "portable">({ message: "How would you like to configure Resend?", options: [ + { + value: "marketplace", + label: "Set up with a Vercel domain", + hint: "Configure Resend, DNS, and project credentials for a domain in Vercel", + }, { value: "connect", - label: "Set up Vercel Connect", - hint: "Provision credentials, deploy, and configure the webhook", + label: "Use an existing Resend account", + hint: "Sign in to create a dedicated credential for this agent", }, { value: "portable", - label: "Use portable credentials", - hint: "Store the API key locally and configure the webhook manually", + label: "Configure manually", + hint: "Use environment variables and configure the webhook yourself", + }, + ], + initialValue: context.environment.vercel.kind === "available" ? "marketplace" : "portable", + }); +} + +function marketplaceDomain(resource: ResendMarketplaceResource): string | undefined { + const domain = (resource.metadata?.domain ?? resource.externalResourceId)?.trim().toLowerCase(); + if (domain === undefined) return undefined; + return /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}$/u.test(domain) ? domain : undefined; +} + +async function chooseMarketplaceResource( + context: IntegrationSetupContext, + resources: readonly ResendMarketplaceResource[], +): Promise { + if (resources.length === 0) return "create"; + if (resources.length === 1 || context.yes) return resources[0]!; + const selected = await context.ui.prompter.select({ + message: "Resend Marketplace resource", + options: [ + ...resources.map((resource) => ({ + value: resource.id, + label: marketplaceDomain(resource) ?? resource.name, + hint: resource.status ?? "Existing Resend resource", + })), + { value: "create", label: "Configure another Vercel domain" }, + ], + initialValue: resources[0]?.id, + }); + return selected === "create" + ? "create" + : (resources.find((resource) => resource.id === selected) ?? "create"); +} + +const ADD_VERCEL_DOMAIN = "__add-vercel-domain__"; + +async function selectMarketplaceDomain( + context: IntegrationSetupContext, + deps: ResendSetupDeps, + project: Awaited>, +): Promise { + const domains = await withSpinner(context.ui.prompter, "Checking Vercel domains...", () => + deps.listDomains({ + projectRoot: context.appRoot, + project, + signal: context.signal, + }), + ); + const openDomainSetup = (): "cancelled" => { + const url = "https://vercel.com/domains"; + context.ui.prompter.note( + `Add or purchase a domain in Vercel, then rerun \`eve add channel/resend\`.\n${url}`, + "Vercel domain required", + { tone: "warning" }, + ); + deps.openUrl(url); + return "cancelled"; + }; + if (domains.length === 0) return openDomainSetup(); + const recommended = domains[0]!; + if (context.yes) return recommended; + const selected = await context.ui.prompter.select({ + message: "Domain for Resend", + description: "Resend will open Vercel web to confirm account, billing, and DNS setup.", + search: true, + placeholder: "type to filter domains", + options: [ + { + value: recommended, + label: recommended, + hint: "Current production domain · recommended", + featured: true, }, + { + value: ADD_VERCEL_DOMAIN, + label: "Add or purchase a domain in Vercel", + featured: true, + trailingAction: true, + }, + ...domains.slice(1).map((domain, index) => ({ + value: domain, + label: domain, + featured: index < 3, + })), ], - initialValue: context.environment.vercel.kind === "available" ? "connect" : "portable", + initialValue: recommended, }); + return selected === ADD_VERCEL_DOMAIN ? openDomainSetup() : selected; +} + +async function setupMarketplace( + context: IntegrationSetupContext, + deps: ResendSetupDeps, +): Promise { + if (context.environment.vercel.kind === "unavailable") { + throw new Error( + "Vercel Marketplace requires an authenticated Vercel CLI. Run `vercel login`, then retry Resend setup.", + ); + } + const project = await deps.ensureVercelProject({ + appRoot: context.appRoot, + prompter: context.ui.prompter, + signal: context.signal, + }); + const resources = await withSpinner( + context.ui.prompter, + "Checking Resend Marketplace resources...", + () => + deps.listMarketplaceResources({ + projectRoot: context.appRoot, + project, + signal: context.signal, + }), + ); + let resource = await chooseMarketplaceResource(context, resources); + if (resource === "create") { + const domain = await selectMarketplaceDomain(context, deps, project); + if (domain === "cancelled") return { kind: "cancelled" }; + resource = await deps.provisionMarketplaceResource({ + domain, + log: context.ui.prompter.log, + projectRoot: context.appRoot, + project, + signal: context.signal, + }); + } + await deps.connectMarketplaceResource({ + resource, + log: context.ui.prompter.log, + projectRoot: context.appRoot, + project, + signal: context.signal, + }); + const resourceDomain = marketplaceDomain(resource); + if (resourceDomain !== undefined) { + resource = await deps.waitForMarketplaceDomain({ + resource, + domain: resourceDomain, + log: context.ui.prompter.log, + projectRoot: context.appRoot, + project, + signal: context.signal, + }); + } + const domain = marketplaceDomain(resource); + const fromAddressQuestion = + domain === undefined + ? text({ + key: "resend-from-address", + message: "Agent email address", + required: true, + validate: validateEmail, + }) + : text({ + key: "resend-from-address", + message: "Agent email address", + detected: `eve@${domain}`, + required: true, + validate: validateEmail, + }); + const fromAddress = await context.ui.asker.ask(fromAddressQuestion); + const fromName = await context.ui.asker.ask( + text({ + key: "resend-from-name", + message: "From name (optional)", + detected: "Eve", + required: false, + }), + ); + await deps.writeTextFile( + join(context.appRoot, "agent/channels/resend.ts"), + channelTemplate({ fromAddress: fromAddress.trim(), fromName: fromName.trim() }), + { force: context.force }, + ); + const deployed = await deps.deploy({ + appRoot: context.appRoot, + prompter: context.ui.prompter, + interactive: true, + signal: context.signal, + }); + if (deployed.kind !== "deployed" || deployed.productionUrl === undefined) { + throw new Error( + `Resend Marketplace resource ${resource.name} is ready, but setup could not determine the production URL. Run \`vercel deploy --prod\`, then rerun \`eve add channel/resend\`.`, + ); + } + const endpoint = new URL("/eve/v1/resend", deployed.productionUrl).href; + const authorization = await deps.authorizeMarketplaceSetup({ + log: context.ui.prompter.log, + projectRoot: context.appRoot, + orgId: project.orgId, + signal: context.signal, + }); + const webhook = await deps.reconcileMarketplaceWebhook({ + accessToken: authorization.accessToken, + endpoint, + signal: context.signal, + }); + try { + await writeProductionSecret(context, webhook.signingSecret, deps); + const redeployed = await deps.deploy({ + appRoot: context.appRoot, + prompter: context.ui.prompter, + interactive: true, + signal: context.signal, + }); + if (redeployed.kind !== "deployed") throw new Error("Production redeploy was cancelled."); + } catch (error) { + await deps + .deleteMarketplaceWebhooks({ + accessToken: authorization.accessToken, + ids: [webhook.id], + signal: context.signal, + }) + .catch(() => {}); + await authorization.cleanup().catch(() => {}); + throw error; + } + await deps + .deleteMarketplaceWebhooks({ + accessToken: authorization.accessToken, + ids: webhook.previousIds.filter((id) => id !== webhook.id), + signal: context.signal, + }) + .catch(() => {}); + await authorization.cleanup(); + context.ui.nextSteps([ + `Resend endpoint: ${endpoint}`, + `Send an email to ${fromAddress.trim()} and reply to smoke-test the conversation.`, + ]); + return { + kind: "done", + facts: [ + ...(domain === undefined ? [] : [{ label: "Resend domain", value: domain }]), + { label: "Resend webhook", value: endpoint, kind: "url" as const }, + ], + }; } async function writeProductionSecret( @@ -148,25 +419,54 @@ export async function setupResend( ): Promise { try { const destination = await chooseDestination(context); + if (destination === "marketplace") return await setupMarketplace(context, deps); if (destination === "connect" && context.environment.vercel.kind === "unavailable") { throw new Error( - "Vercel Connect requires an authenticated Vercel CLI. Run `vercel login`, then retry Resend setup.", + "Using an existing Resend account requires an authenticated Vercel CLI. Run `vercel login`, then retry Resend setup.", ); } - const instructions = [ - "Use a full-access Resend API key. Setup needs webhook access, and the adapter fetches received-email contents.", - "Create a key: https://resend.com/api-keys", - ]; - if (context.ui.prompter.acknowledge) { - await context.ui.prompter.acknowledge({ message: "Resend API key", lines: instructions }); + let setupAuthorization: Awaited> | undefined; + let createdApiKey: Awaited> | undefined; + let apiKey: string; + if (destination === "connect") { + const project = await deps.ensureVercelProject({ + appRoot: context.appRoot, + prompter: context.ui.prompter, + signal: context.signal, + }); + setupAuthorization = await deps.authorizeMarketplaceSetup({ + log: context.ui.prompter.log, + projectRoot: context.appRoot, + orgId: project.orgId, + signal: context.signal, + }); + createdApiKey = await deps.createApiKey({ + accessToken: setupAuthorization.accessToken, + name: `eve · ${await deps.deriveConnectorSlug(context.appRoot)}`, + signal: context.signal, + }); + apiKey = createdApiKey.token; } else { - context.ui.prompter.log.info(instructions.join("\n")); + const instructions = [ + "Use a full-access Resend API key. Setup needs webhook access, and the adapter fetches received-email contents.", + "Create a key: https://resend.com/api-keys", + ]; + if (context.ui.prompter.acknowledge) { + await context.ui.prompter.acknowledge({ message: "Resend API key", lines: instructions }); + } else { + context.ui.prompter.log.info(instructions.join("\n")); + } + apiKey = ( + await context.ui.asker.ask( + text({ + key: "resend-api-key", + message: "Resend API key", + required: true, + sensitive: true, + }), + ) + ).trim(); } - const apiKey = ( - await context.ui.asker.ask( - text({ key: "resend-api-key", message: "Resend API key", required: true, sensitive: true }), - ) - ).trim(); await deps.validateApiKey(apiKey, context.signal); const suggestedFromAddress = await deps.suggestFromAddress(apiKey, context.signal); const defaultFromAddress = suggestedFromAddress ?? "onboarding@resend.dev"; @@ -193,7 +493,12 @@ export async function setupResend( const fromAddress = (await context.ui.asker.ask(fromAddressQuestion)).trim(); const fromName = ( await context.ui.asker.ask( - text({ key: "resend-from-name", message: "From name (optional)", required: false }), + text({ + key: "resend-from-name", + message: "From name (optional)", + detected: "Eve", + required: false, + }), ) ).trim(); @@ -205,7 +510,7 @@ export async function setupResend( }); await deps.writeTextFile( join(context.appRoot, "agent/channels/resend.ts"), - channelTemplate({ apiKey, fromAddress, fromName }), + channelTemplate({ fromAddress, fromName }), { force: context.force }, ); context.ui.nextSteps([ @@ -215,23 +520,25 @@ export async function setupResend( return { kind: "done" }; } - const project = await deps.ensureVercelProject({ + await deps.ensureVercelProject({ appRoot: context.appRoot, prompter: context.ui.prompter, signal: context.signal, }); - const connector = await deps.provisionConnector({ - apiKey, - log: context.ui.prompter.log, - project, - projectRoot: context.appRoot, - slug: `resend-${await deps.deriveConnectorSlug(context.appRoot)}`, - signal: context.signal, - }); try { + const savedApiKey = await deps.runVercel( + ["env", "add", "RESEND_API_KEY", "production", "--force", "--yes"], + { + cwd: context.appRoot, + nonInteractive: true, + signal: context.signal, + stdin: apiKey, + }, + ); + if (!savedApiKey) throw new Error("Could not save RESEND_API_KEY to Vercel production."); await deps.writeTextFile( join(context.appRoot, "agent/channels/resend.ts"), - channelTemplate({ apiKey, connectorUid: connector.uid, fromAddress, fromName }), + channelTemplate({ fromAddress, fromName }), { force: context.force }, ); const deployed = await deps.deploy({ @@ -242,7 +549,7 @@ export async function setupResend( }); if (deployed.kind !== "deployed" || deployed.productionUrl === undefined) { throw new Error( - `Connector ${connector.uid} is ready, but setup could not determine the production URL. Run \`vercel deploy --prod\`, then re-run Resend setup.`, + "The Resend credential is ready, but setup could not determine the production URL. Run `vercel deploy --prod`, then rerun Resend setup.", ); } const endpoint = new URL("/eve/v1/resend", deployed.productionUrl).href; @@ -274,15 +581,26 @@ export async function setupResend( } } } + await setupAuthorization?.cleanup(); context.ui.nextSteps([ `Resend endpoint: ${endpoint}`, `Send from ${fromAddress}; configure a receiving domain in Resend, then send an email and reply to smoke-test the thread.`, ]); return { kind: "done", facts: [{ label: "Resend webhook", value: endpoint, kind: "url" }] }; } catch (error) { + if (createdApiKey !== undefined && setupAuthorization !== undefined) { + await deps + .deleteApiKey({ + accessToken: setupAuthorization.accessToken, + id: createdApiKey.id, + signal: context.signal, + }) + .catch(() => {}); + await setupAuthorization.cleanup().catch(() => {}); + } const reason = error instanceof Error ? error.message : String(error); throw new Error( - `${reason}\nResend connector ${connector.uid} may persist. Inspect it with \`vercel connect list\` and re-run \`eve add channel/resend\` to recover.`, + `${reason}\nThe generated Resend API key may persist in the project's production environment. Rerun \`eve add channel/resend\` to recover.`, ); } } catch (error) { diff --git a/research/resend-marketplace-domain-setup.md b/research/resend-marketplace-domain-setup.md new file mode 100644 index 0000000000..a3104c72d5 --- /dev/null +++ b/research/resend-marketplace-domain-setup.md @@ -0,0 +1,278 @@ +--- +issue: TBD +status: proposed +last_updated: "2026-08-04" +--- + +# Resend Marketplace domain setup + +## Recommendation + +Make Vercel Marketplace the preferred domain-provisioning path for `eve add +channel/resend`. Reuse the existing Resend Marketplace product and signed Domain +Connect flow instead of having eve mutate DNS records directly. + +Keep manual Vercel Connect API-key and portable environment-variable paths for +existing Resend accounts and domains outside Marketplace. + +The flow should converge three starting states on one ready configuration: + +```text +A. Resend resource + domain exist ──> select/reuse resource ─────────────┐ + │ +B. Vercel domain exists ────────────> provision Resend resource ────────┤ + │ +C. No domain exists ────────────────> Vercel web domain setup ─────────┤ + └─> resume Resend provisioning ──┘ + │ + signed Domain Connect <───────────┘ + │ + v + verify sending + receiving + │ + v + scaffold eve@ + deploy + webhook +``` + +The outcome should be the same whether the user starts with no email setup, a +Vercel-owned domain, or an existing Resend Marketplace resource. + +## Existing platform capabilities + +The published Marketplace integration uses integration slug `resend` and product +slug `resend-email`. Provisioning requires a domain and region: + +```ts +{ + domain: string; + region: "us-east-1" | "eu-west-1" | "sa-east-1" | "ap-northeast-1"; +} +``` + +Vercel CLI can provision and connect the resource: + +```sh +vercel integration add resend \ + --metadata domain=example.com \ + --metadata region=us-east-1 \ + --environment production \ + --json +``` + +The flow can install the integration, accept terms or hand off to a browser, +provision the resource, connect it to the linked project, synchronize +`RESEND_API_KEY`, and return resource and installation identifiers. The product +advertises project connections, SSO, secret synchronization, and immediate +secret rotation. + +Front already owns Resend Domain Connect templates for sending, receiving, +combined sending and receiving, verification, and click tracking. The combined +`resend.com.mail-send-and-receive` template includes DKIM, outbound MX/SPF, +inbound MX, tracking CNAME, and CAA records. + +## Proposed guided flow + +The Resend setup destination picker should lead with Marketplace: + +```text +How would you like to configure Resend? + +● Set up with Vercel Marketplace + Create or select a Resend account, domain, and project credential + +○ Use an existing Resend API key + Store a full-access key in Vercel Connect + +○ Use portable credentials +``` + +For the Marketplace path: + +1. Require authenticated Vercel CLI access and ensure the directory is linked + to a project. +2. Inventory Resend Marketplace resources and Vercel-owned domains for the + linked team and project using existing Vercel CLI/API surfaces. +3. Classify the starting state and follow the matching path below. Keep eve's + orchestration thin: select or reuse resources locally, and hand off account, + domain, billing, terms, and DNS workflows to Vercel/Resend web surfaces. +4. Converge on one selected Resend resource and configured domain. +5. Open or print the Resend onboarding URL when browser completion is required. + Explain that **Auto configure** applies the provider-signed DNS setup. +6. Poll or re-check resource/domain status after the user completes onboarding. +7. Require both sending and receiving to be enabled before using the domain for + automatic replies. If only sending is configured, direct the user to enable + receiving rather than mutating inbound MX records from eve. +8. Connect the resource to the linked project for production if it is not + already connected, preserving Marketplace ownership of `RESEND_API_KEY`. +9. Prefill `eve@` as the editable agent email address. +10. Continue with channel scaffolding, deployment, webhook setup, and a + send/reply smoke test. + +### A. Existing Resend resource and domain + +List compatible `resend-email` resources, showing their configured domain, +readiness, and whether they are connected to the linked project. Prefer an exact +project connection, then a send-and-receive-ready resource on the same team. +Let the user select when more than one remains. + +Reuse the resource and its domain. Do not reprovision Resend, rotate its key, or +reapply DNS when the resource is already ready. If it is not connected to the +project, add only the production project connection. If its domain is pending or +send-only, resume the provider onboarding/configuration flow instead of creating +another resource. + +### B. Existing Vercel domain, no matching Resend resource + +List domains owned by the linked Vercel account and ask which one to configure. +Prefer a dedicated subdomain such as `mail.example.com` or `agent.example.com`; +do not change apex MX records without explicit user intent. + +Invoke the existing Marketplace CLI provisioning flow with the selected domain, +region, production environment, and JSON output: + +```sh +vercel integration add resend \ + --metadata domain=mail.example.com \ + --metadata region=us-east-1 \ + --environment production \ + --json +``` + +Then complete Resend onboarding and signed Domain Connect configuration. + +### C. No Vercel domain + +Do not implement domain search, pricing, checkout, registration, transfer, or +purchase confirmation in eve. Present a browser handoff to Vercel's domain +surface, where Vercel owns billing, registrant details, availability, renewal +terms, permissions, and purchase recovery. + +The handoff should preserve the linked team and return/resume intent when the web +surface supports it. Otherwise, print the exact Vercel domains URL and tell the +user to rerun `eve add channel/resend` after adding or purchasing a domain. The +CLI may poll for a newly available team domain while the browser is open, but it +must also support a clean exit and later rerun. + +Once a domain appears, resume path B: select a safe email subdomain, provision +the Resend Marketplace resource, complete signed Domain Connect, and verify +sending and receiving. If the user does not want to add a domain, retain the +manual existing-account and portable alternatives rather than blocking all +channel scaffolding. + +A rerun must detect the furthest completed state and resume from there without +duplicating a domain purchase, Marketplace resource, DNS application, or project +connection. + +## DNS ownership and safety + +DNS remains owned by Resend's signed Domain Connect flow. eve must not synthesize +the apply URL or create the records itself because the provider supplies dynamic +DKIM, return-path, inbound MX, priority, region, and tracking values. The signed +flow also proves which provider configuration the records belong to. + +Inbound MX changes can disrupt an existing mail provider. Setup should favor a +dedicated subdomain and clearly warn before configuring a root domain that +already has MX records. Exact conflicts and replacement behavior belong in the +Domain Connect confirmation UI. + +## Credential boundary + +Marketplace currently provisions `RESEND_API_KEY` as a resource secret and +syncs it into connected projects. eve should not read that environment value +back and copy it into Connect; doing so duplicates credential ownership and +breaks Marketplace rotation semantics. + +Before a Native→Connect bridge exists, the Marketplace path may generate the +adapter's standard environment-backed credential behavior: + +```ts +createResendAdapter({ + fromAddress: "eve@example.com", +}); +``` + +A future bridge should let the Marketplace resource issue an app credential to +Connect, after which generated code can use `connectResendApiKey(...)` without +copying a static key. Manual API-key setup remains a separate Connect-backed +fallback. + +Automatic webhook management on the Marketplace path needs one of: + +- a Marketplace resource-token endpoint that can mint a bounded Resend token; +- a Native→Connect resource authorization; +- a Resend Marketplace operation that reconciles the webhook on eve's behalf; +- or a documented manual webhook step. + +Do not access Marketplace resource secrets merely to automate webhook creation. + +## Required coordination with Resend + +Confirm before making Marketplace the default: + +1. Does `resend-email` currently configure sending only or the combined + `resend.com.mail-send-and-receive` service? +2. Can provisioning explicitly request receiving support? +3. Does the product accept a subdomain under a Vercel-owned apex domain, and + can Marketplace create that subdomain without attaching it as a project + domain? +4. What identifier is returned as `externalResourceId`, and can safe resource + metadata expose the configured domain? +5. Can resource discovery reliably match an existing Resend resource to a + domain and project without reading secrets? +6. Is a Marketplace `resourceTokenEndpoint` configured or planned, and what + scopes would its token carry? +7. Can Marketplace provisioning accept or later configure an `email.received` + webhook destination? +8. Can resource status distinguish DNS pending, send-ready, and receive-ready? +9. What is the expected Native→Connect bridge contract and timeline? + +## Repository boundaries + +- **Resend Marketplace integration:** account/resource creation, billing, API-key + lifecycle, provider onboarding, and initiating signed Domain Connect. +- **Front:** domain search/purchase/transfer, Marketplace checkout/resource + selection, and Domain Connect confirmation UI and templates. +- **Vercel API:** Marketplace installation/resource/project connection APIs and + resource-token or Native→Connect bridge primitives. +- **Vercel CLI:** existing `integration add`, resource discovery, connection, + browser handoff, and JSON results used by eve orchestration. +- **Connect:** runtime app-token resolution after a bridge exists, or manual + API-key storage for the fallback path. +- **eve:** thin setup coordination, resource/domain discovery, browser handoffs, + safe selection defaults, generated channel, deployment, webhook reconciliation + when an authorized credential path exists, and smoke-test guidance. eve does + not own domain commerce or DNS mutation. + +## Tests + +At minimum cover: + +- existing compatible resource selection and reuse; +- multiple-resource selection prefers the linked project and ready domain; +- an existing resource already connected to the project is left untouched; +- a pending or send-only existing resource resumes setup without duplication; +- existing-domain selection and dedicated-subdomain recommendation; +- no-domain path opens or prints the Vercel web domain flow without implementing + availability, pricing, checkout, or purchase in eve; +- browser cancellation and later rerun are clean; +- a domain added through Vercel web resumes Marketplace provisioning on rerun; +- Marketplace provisioning arguments and parsed JSON result; +- browser/terms handoff and cancellation; +- send-only status does not proceed as reply-ready; +- send-and-receive status prefills `eve@`; +- no direct DNS mutation or unsigned Domain Connect URL construction; +- no Marketplace secret readback or copy into Connect; +- reruns do not duplicate resources, DNS setup, or project connections; +- partial failure reports domain, resource, and installation IDs with recovery + commands; +- manual Connect and portable fallbacks remain available. + +## Rollout + +Start behind an explicit Marketplace option while confirming receiving and +resource-token behavior with Resend. The first implementation should orchestrate +existing CLI discovery/provisioning plus browser handoffs, not reproduce +Marketplace checkout or domain management in eve. Promote Marketplace to the +recommended first option once a selected domain can be proven send-and-receive +ready and the webhook can be configured without copying its static Marketplace +secret.