Vercel Logo

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.

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
  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:

Loading diagram...
// ❌ 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.

1. Scaffold the Plugin

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

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:

plugins/resend/steps/send-email.ts
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

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

5. Configure Environment

Add your API key to .env.local:

.env.local
RESEND_API_KEY=re_your_key_here
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 authenticate and store their own API keys via Settings → Integrations. The fetchCredentials() function would retrieve them from the encrypted database. Same pattern, different credential source.

See Environment Variables and Sensitive Environment Variables for Vercel's best practices on managing secrets.

6. Restart the Dev Server

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

Try It

Reflection Prompt

Predict the Output

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?

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.

  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)
  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:

{
  "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:

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

Run again. This time it works.

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.

Question

Why do we fetch credentials inside the step instead of passing them as input parameters?

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

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