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.
Outcome
You'll scaffold an email plugin, customize it with real Resend API calls, and send an actual email through your workflow.
Fast Track
- Get a Resend API key from resend.com/api-keys
- Run
pnpm create-pluginto scaffold the plugin - Customize the generated step with Resend API logic
- 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:
// ❌ 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
pnpm create-pluginAnswer 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 resend3. Customize the Step Function
Open plugins/resend/steps/send-email.ts. The scaffolding generates a template — replace the API call with Resend's SDK:
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
export type ResendCredentials = {
RESEND_API_KEY?: string;
};5. Configure Environment
Add your API key to .env.local:
RESEND_API_KEY=re_your_key_here6. Restart the Dev Server
# Stop the server (Ctrl+C), then:
pnpm devTry It
- Add Send Email node after your trigger
- Configure: your Resend account email, subject "Test from Workflow", body "It works!"
- Run the workflow
- Check your inbox (or Resend dashboard at resend.com/emails)
- 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 devRun again. This time it works.
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
Was this helpful?