---
title: "Build an Email Plugin"
description: "Build an email plugin using the pattern you learned. Add real API calls, credential management, and send actual emails through your workflow."
canonical_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/resend-plugin"
md_url: "https://vercel.com/academy/visual-workflow-builder-on-vercel/resend-plugin.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-04-11T06:12:58.435Z"
content_type: "lesson"
course: "visual-workflow-builder-on-vercel"
course_title: "Build Visual Workflow Plugins on Vercel"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Build an Email Plugin

# Build an Email Plugin

You know the plugin folder pattern from the Shout plugin. Now apply it to something real — a plugin that sends actual emails. We'll use Resend (swap in SendGrid, Postmark, whatever you prefer). The new complexity: credentials. API keys shouldn't be passed as step inputs — they'd be serialized to the workflow's event log. You'll fetch them inside the step instead.

\*\*Note: Building on Lesson 3\*\*

Same folder structure as the Shout plugin. Same registration pattern. The new part: `fetchCredentials()` in your step to securely access API keys.

## Outcome

You'll scaffold an email plugin, customize it with real Resend API calls, and send an actual email through your workflow.

## Fast Track

1. Get a Resend API key from [resend.com/api-keys](https://resend.com/api-keys)
2. Run `pnpm create-plugin` to scaffold the plugin
3. Customize the generated step with Resend API logic
4. Send a real email via workflow

## What's New: Credentials

The Shout plugin had no secrets. Resend needs an API key. Here's the critical pattern:

```mermaid height=1000
flowchart TB
    A[Step executes] --> B{integrationId provided?}
    B -->|Yes| C[fetchCredentials from DB]
    B -->|No| D[Use process.env fallback]
    C --> E[Call Resend API]
    D --> E
    E --> F[Key stays inside step function]
```

```typescript
// ❌ BAD: Key gets serialized to workflow event log
async function sendEmailStep(input: { apiKey: string, to: string }) {
  // apiKey is now persisted in the event log for replay
}

// ✅ GOOD: Fetch credentials inside the step
async function sendEmailStep(input: { to: string }) {
  const apiKey = process.env.RESEND_API_KEY; // or fetchCredentials()
  // apiKey never leaves this function's scope
}
```

## Hands-on Exercise

The scaffolding tool generates the plugin structure. Your job: customize the step function with real Resend API calls. This lesson assumes you're extending the stripped-down starter rather than uncovering a built-in email integration.

### 1. Scaffold the Plugin

```bash
pnpm create-plugin
```

Answer the prompts:

- **Integration name:** `resend`
- **Description:** `Send emails via Resend`
- **Action slug:** `send-email`
- **Action description:** `Send an email`

This creates the plugin folder:

```
plugins/resend/
├── index.ts           → Plugin definition with formFields, actions
├── icon.tsx           → Icon component
├── credentials.ts     → Type for credentials
├── test.ts            → Connection test function
└── steps/
    └── send-email.ts  → "use step" function (customize this)
```

### 2. Add the Resend SDK

```bash
pnpm add resend
```

### 3. Customize the Step Function

Open `plugins/resend/steps/send-email.ts`. The scaffolding generates a template — replace the API call with Resend's SDK:

```typescript title="plugins/resend/steps/send-email.ts" {3,12-16,27-49}
import "server-only";

import { Resend } from "resend";
import { fetchCredentials } from "@/lib/credential-fetcher";
import { type StepInput, withStepLogging } from "@/lib/steps/step-handler";
import type { ResendCredentials } from "../credentials";

type SendEmailResult =
  | { success: true; id: string }
  | { success: false; error: string };

export type SendEmailCoreInput = {
  emailTo: string;
  emailSubject: string;
  emailBody: string;
};

export type SendEmailInput = StepInput &
  SendEmailCoreInput & {
    integrationId?: string;
  };

async function stepHandler(
  input: SendEmailCoreInput,
  credentials: ResendCredentials
): Promise<SendEmailResult> {
  const apiKey = credentials.RESEND_API_KEY;

  if (!apiKey) {
    return {
      success: false,
      error: "RESEND_API_KEY is not configured.",
    };
  }

  const resend = new Resend(apiKey);

  const result = await resend.emails.send({
    from: "onboarding@resend.dev", // Resend's test sender
    to: input.emailTo,
    subject: input.emailSubject,
    text: input.emailBody,
  });

  if (result.error) {
    return { success: false, error: result.error.message };
  }

  return { success: true, id: result.data?.id || "" };
}

export async function sendEmailStep(
  input: SendEmailInput
): Promise<SendEmailResult> {
  "use step";

  // Fetch from integration, or fall back to env var for local dev
  let credentials: ResendCredentials;
  if (input.integrationId) {
    credentials = await fetchCredentials(input.integrationId) as ResendCredentials;
  } else {
    credentials = {
      RESEND_API_KEY: process.env.RESEND_API_KEY || "",
    };
  }

  return withStepLogging(input, () =>
    stepHandler(
      {
        emailTo: input.emailTo,
        emailSubject: input.emailSubject,
        emailBody: input.emailBody,
      },
      credentials
    )
  );
}

export const _integrationType = "resend";
```

### 4. Update the Credentials Type

```typescript title="plugins/resend/credentials.ts"
export type ResendCredentials = {
  RESEND_API_KEY?: string;
};
```

### 5. Configure Environment

Add your API key to `.env.local`:

```bash title=".env.local"
RESEND_API_KEY=re_your_key_here
```

\*\*Note: Dev vs Production Credentials\*\*

In this course, you're using environment variables in `.env.local` — the step code falls back to `process.env` when no `integrationId` is provided.

In a production multi-tenant app, users would store their own API keys via **Settings → Integrations**. The `fetchCredentials()` function would retrieve them from the database. Same pattern, different credential source.

See [Environment Variables](https://vercel.com/docs/environment-variables) and [Sensitive Environment Variables](https://vercel.com/docs/environment-variables/sensitive-environment-variables) for Vercel's best practices on managing secrets.

### 6. Restart the Dev Server

```bash
# Stop the server (Ctrl+C), then:
pnpm dev
```

## Try It

\*\*Reflection:\*\* Before you run: What will the step logs show? Will you see the API key in any log output? What will happen if the API key is wrong?

\*\*Warning: Resend Test Sender Limitation\*\*

The `onboarding@resend.dev` sender can only send to the email address you signed up with on Resend. Use your Resend account email in the "To" field. To send to other recipients, you'll need to verify your own domain at [resend.com/domains](https://resend.com/domains).

1. Add Send Email node after your trigger
2. Configure: **your Resend account email**, subject "Test from Workflow", body "It works!"
3. Run the workflow
4. Check your inbox (or Resend dashboard at [resend.com/emails](https://resend.com/emails))
5. Check logs — note the step timing, but NO api key visible

Your terminal should show:

```
[Workflow Executor] Starting workflow execution
[Workflow Executor] Executing trigger node
[Workflow Executor] Executing action node: resend/send-email
[Workflow Executor] Step result received: { hasResult: true, resultType: 'object' }
[Workflow Executor] Node execution completed: { nodeId: 'action-1', success: true }
[Workflow Executor] Workflow execution completed: { success: true, ... }
```

Note: The API key is nowhere in that output. That's the whole point of fetching credentials inside the step.

## Debugging: "RESEND\_API\_KEY is not configured"

Your first run will probably fail. In the Runs tab, expand the failed step and check the output:

```json
{
  "error": "RESEND_API_KEY is not configured.",
  "success": false
}
```

**The env var isn't loaded yet.** Next.js usually picks up `.env.local` changes automatically, but if it doesn't, restart the dev server:

```bash
# Stop the server (Ctrl+C), then:
pnpm dev
```

Run again. This time it works.

\*\*Note: Real Debugging\*\*

This isn't a contrived exercise — forgetting to restart after adding env vars is the #1 "why doesn't this work" moment in plugin development. Now you'll recognize it instantly.

```yaml
quiz:
  question: "Why do we fetch credentials inside the step instead of passing them as input parameters?"
  choices:
    - id: "performance"
      text: "Fetching inside is faster"
    - id: "logs"
      text: "Input parameters get serialized to the workflow event log"
    - id: "syntax"
      text: "The SDK requires it"
    - id: "typing"
      text: "TypeScript types work better this way"
  correctAnswerId: "logs"
  feedback: "{\n    correct: \"Bingo. Workflow serializes all step inputs to the event log for deterministic replay. Pass an API key as a parameter and it's persisted. Fetch inside the step and it never leaves that function's scope.\",\n    incorrect: \"Think about how workflows achieve durability. Step inputs are serialized and persisted. What would happen if your API key was in that data?\"\n  }"
```

## Solution

The complete step function handles both development and production credential sources.

The dual-path credential pattern works like this: the `if (input.integrationId)` branch handles production multi-tenant apps where users store their own credentials via Settings → Integrations. The `else` branch handles local development with environment variables. This pattern works in both environments without code changes.

Note that we return `{ success: false }` instead of throwing when credentials are missing. In Lesson 5, you'll refactor this to throw `FatalError` for missing credentials — making it explicit that this failure is permanent and shouldn't retry.

## Commit

```bash
git add plugins/resend
git commit -m "Add Resend email plugin with secure credential handling"
```

## Done

- [ ] Resend API key in `.env`
- [ ] Plugin appears in action grid
- [ ] Credentials fetched inside step (not passed as params)
- [ ] Sent a real email
- [ ] Verified no secrets in logs

\*\*Side Quest: Swap the Provider\*\*


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
