Claude Code Email Automation: Lead Capture to Monetization with Node.js
Build Claude Code email automation for lead magnets, onboarding, consent, retries, analytics, and monetization.
Email automation is not just a function that sends one message after a form submit. A useful revenue path delivers the lead magnet, starts an onboarding sequence, follows up after consultation requests, records consent, handles unsubscribes, suppresses bounces, retries temporary failures, and reports which calls to action actually lead to product purchases or consulting conversations.
Claude Code is valuable here because email touches many files at once: schemas, templates, provider adapters, background jobs, webhook handlers, analytics events, and documentation. When Masa revised the free PDF funnel for this site, the first mistake was building the Resend send function before defining consent, unsubscribe, bounce handling, and CTA analytics. The code worked, but the workflow was brittle. The better approach is to ask Claude Code for a bounded implementation plan first, then let it edit only the selected files.
This guide builds a provider-agnostic Node.js/TypeScript implementation you can adapt for Resend-style or SendGrid-style APIs. It covers lead magnet delivery, onboarding, consultation follow-up, SPF/DKIM/DMARC basics, safe outreach boundaries, rate limits, queue/retry behavior, templates, analytics, and a CTA path toward products, training, and consulting. Pair it with content funnel audits, analytics implementation, and cookie/consent management.
Design the Email System Before Coding
Start by separating message types. A lead magnet is a free PDF, checklist, or template offered in exchange for an email address. Onboarding is the sequence that helps someone use a product, course, or tool after they sign up or buy. Consultation follow-up is the operational email that summarizes a conversation, sends next steps, or asks for a decision.
Do not put all of these into one generic newsletter bucket. They have different consent rules, metrics, tone, and unsubscribe expectations.
| Goal | Recipient | Example email | Revenue path | Risk to control |
|---|---|---|---|---|
| Lead capture | Reader who requested a PDF | Download link and related guide | Free PDF to products | Save consent and unsubscribe URL |
| Onboarding | Customer or training participant | Start guide, checklist, common blockers | Templates, course, extra support | Do not mix receipts with aggressive promotion |
| Consultation follow-up | Qualified lead | Meeting notes, proposal, next booking | Training and consulting | Personalize from the actual conversation |
| Re-engagement | Consented but inactive reader | Practical failure story or major update | Product or consultation | Watch frequency, bounce rate, and opt-outs |
Plain definitions matter. SPF is a DNS record that says which senders are allowed to send mail for your domain. DKIM adds a signature so receivers can verify the message was authorized and not changed in transit. DMARC tells receivers what policy to apply when SPF or DKIM alignment fails. A bounce means delivery failed. A rate limit means the provider is slowing or rejecting requests because you are sending too fast or have hit a quota or reputation boundary.
Use official documents as the source of truth. For Gmail delivery requirements, check Google’s email sender guidelines. For provider setup, start with Resend domain management or Twilio SendGrid domain authentication. DMARC was updated in 2026 by RFC 9989, which obsoletes the older RFC 7489. For U.S. commercial email, review the FTC’s CAN-SPAM compliance guide. This article is implementation guidance, not legal advice.
flowchart LR
Visitor["Article reader"]
Form["Lead capture form"]
Consent["Consent log"]
Queue["Email queue"]
Provider["Resend / SendGrid"]
Inbox["Inbox"]
Webhook["Delivery events"]
Analytics["Analytics"]
Offer["Product / training / consulting"]
Visitor --> Form --> Consent --> Queue --> Provider --> Inbox
Provider --> Webhook --> Analytics --> Offer
Inbox --> Offer
Prompt Claude Code with Boundaries
A vague prompt like “add email automation” often creates only a send function. Give Claude Code the business goal, allowed files, provider boundary, consent rules, retry behavior, and verification requirements.
Implement email automation in this repository.
The goals are lead magnet delivery, a 3-email onboarding sequence, and consultation follow-up.
Constraints:
- Use Node.js 20+ and TypeScript.
- Create a provider adapter that can switch between Resend-style and SendGrid-style APIs.
- Keep API keys server-side only. Never expose them to the browser.
- Add schemas for lead, email job, unsubscribe, and provider event payloads.
- Retry 429 and 5xx responses with exponential backoff.
- Do not send to unsubscribed, complained, or suppressed addresses.
- Put repeated hard bounces on a suppression list.
- Include text body, HTML body, unsubscribe URL, and clear sender details.
- Link official provider and sender-authentication docs in the README.
- Add runnable scripts and focused tests.
First return the design table and file list. Wait for approval before editing.
This is the difference between asking Claude Code for code and asking it for an operating surface. Email automation affects trust and revenue, so the review target must include behavior after the send, not only the API call.
Copy-Paste Starter Implementation
The following starter uses a local JSON file as a small queue so it can run in a demo project. In production, replace it with Postgres, Redis, SQS, Cloud Tasks, or another durable queue with locking and audit logs.
{
"type": "module",
"scripts": {
"lead:send": "tsx scripts/send-lead-magnet.ts",
"email:worker": "tsx scripts/email-worker.ts"
},
"dependencies": {
"zod": "latest"
},
"devDependencies": {
"@types/node": "latest",
"tsx": "latest",
"typescript": "latest"
}
}
// src/email/schema.ts
import { z } from "zod";
export const leadSchema = z.object({
email: z.string().email(),
name: z.string().trim().min(1).max(80),
locale: z.enum(["ja", "en", "zh", "ko", "es", "fr", "de", "pt", "hi", "id"]).default("en"),
source: z.enum(["article", "product", "workshop", "consultation"]),
consentAt: z.string().datetime(),
tags: z.array(z.string()).default([]),
});
export const sendMessageSchema = z.object({
to: z.string().email(),
from: z.string().email(),
fromName: z.string().min(1),
replyTo: z.string().email().optional(),
subject: z.string().min(1).max(120),
text: z.string().min(1),
html: z.string().min(1),
unsubscribeUrl: z.string().url(),
category: z.enum(["lead_magnet", "onboarding", "consultation_followup"]),
metadata: z.record(z.string()).default({}),
});
export const emailJobSchema = z.object({
message: sendMessageSchema,
maxAttempts: z.number().int().min(1).max(8).default(4),
});
export const providerEventSchema = z.object({
provider: z.enum(["resend", "sendgrid", "unknown"]),
type: z.enum(["delivered", "bounce", "complaint", "unsubscribe", "open", "click", "deferred"]),
email: z.string().email().optional(),
providerMessageId: z.string().optional(),
reason: z.string().optional(),
occurredAt: z.string().datetime(),
});
export type Lead = z.infer<typeof leadSchema>;
export type SendMessage = z.infer<typeof sendMessageSchema>;
export type EmailJobInput = z.infer<typeof emailJobSchema>;
export type ProviderEvent = z.infer<typeof providerEventSchema>;
// src/email/provider.ts
import { randomUUID } from "node:crypto";
import type { SendMessage } from "./schema";
type SendResult = {
providerMessageId: string;
acceptedAt: string;
};
export interface EmailProvider {
send(message: SendMessage): Promise<SendResult>;
}
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing env: ${name}`);
return value;
}
async function parseProviderError(response: Response): Promise<Error> {
const body = await response.text().catch(() => "");
const retryable = response.status === 429 || response.status >= 500;
const error = new Error(`Email provider error ${response.status}: ${body || response.statusText}`);
(error as Error & { retryable?: boolean }).retryable = retryable;
return error;
}
export class ResendProvider implements EmailProvider {
async send(message: SendMessage): Promise<SendResult> {
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${requiredEnv("RESEND_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: `${message.fromName} <${message.from}>`,
to: [message.to],
reply_to: message.replyTo,
subject: message.subject,
text: message.text,
html: message.html,
headers: {
"List-Unsubscribe": `<${message.unsubscribeUrl}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
}),
});
if (!response.ok) throw await parseProviderError(response);
const data = (await response.json().catch(() => ({}))) as { id?: string };
return { providerMessageId: data.id ?? randomUUID(), acceptedAt: new Date().toISOString() };
}
}
export class SendGridProvider implements EmailProvider {
async send(message: SendMessage): Promise<SendResult> {
const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
method: "POST",
headers: {
Authorization: `Bearer ${requiredEnv("SENDGRID_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
personalizations: [{ to: [{ email: message.to }], custom_args: message.metadata }],
from: { email: message.from, name: message.fromName },
reply_to: message.replyTo ? { email: message.replyTo } : undefined,
subject: message.subject,
content: [
{ type: "text/plain", value: message.text },
{ type: "text/html", value: message.html },
],
headers: {
"List-Unsubscribe": `<${message.unsubscribeUrl}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
}),
});
if (!response.ok) throw await parseProviderError(response);
return {
providerMessageId: response.headers.get("x-message-id") ?? randomUUID(),
acceptedAt: new Date().toISOString(),
};
}
}
export function createEmailProvider(): EmailProvider {
return process.env.EMAIL_PROVIDER === "sendgrid" ? new SendGridProvider() : new ResendProvider();
}
export function isRetryable(error: unknown): boolean {
return Boolean((error as { retryable?: boolean })?.retryable);
}
// src/email/templates.ts
import type { Lead, SendMessage } from "./schema";
const escapeHtml = (value: string) =>
value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]!));
export function renderLeadMagnetEmail(input: {
lead: Lead;
downloadUrl: string;
unsubscribeUrl: string;
}): SendMessage {
const name = escapeHtml(input.lead.name);
return {
to: input.lead.email,
from: "hello@example.com",
fromName: "ClaudeCodeLab",
replyTo: "masa@example.com",
subject: "Your Claude Code email automation checklist",
category: "lead_magnet",
unsubscribeUrl: input.unsubscribeUrl,
metadata: { source: input.lead.source, locale: input.lead.locale },
text: [
`Hi ${input.lead.name},`,
"",
"Thanks for requesting the Claude Code email automation checklist.",
`Download it here: ${input.downloadUrl}`,
"",
"Next, connect this email to a product, training offer, or consultation path.",
`Unsubscribe: ${input.unsubscribeUrl}`,
].join("\n"),
html: `<!doctype html>
<html lang="en">
<body style="font-family: sans-serif; line-height: 1.7; color: #1f2937;">
<p>Hi ${name},</p>
<p>Thanks for requesting the Claude Code email automation checklist.</p>
<p><a href="${input.downloadUrl}">Download the checklist</a></p>
<p>Next, connect this email to one clear CTA: product, training, or consultation.</p>
<p style="font-size: 12px; color: #6b7280;"><a href="${input.unsubscribeUrl}">Unsubscribe</a></p>
</body>
</html>`,
};
}
// src/email/queue.ts
import { readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { emailJobSchema, type EmailJobInput } from "./schema";
type StoredJob = EmailJobInput & {
id: string;
status: "scheduled" | "processing" | "sent" | "failed";
attempts: number;
nextAttemptAt: string;
lastError?: string;
};
const queueFile = process.env.EMAIL_QUEUE_FILE ?? ".email-queue.json";
async function loadQueue(): Promise<StoredJob[]> {
if (!existsSync(queueFile)) return [];
return JSON.parse(await readFile(queueFile, "utf8")) as StoredJob[];
}
async function saveQueue(jobs: StoredJob[]) {
await writeFile(queueFile, JSON.stringify(jobs, null, 2) + "\n");
}
export async function enqueueEmail(input: EmailJobInput) {
const parsed = emailJobSchema.parse(input);
const jobs = await loadQueue();
const job: StoredJob = {
...parsed,
id: randomUUID(),
status: "scheduled",
attempts: 0,
nextAttemptAt: new Date().toISOString(),
};
jobs.push(job);
await saveQueue(jobs);
return job.id;
}
export async function claimDueJobs(limit = 5): Promise<StoredJob[]> {
const now = Date.now();
const jobs = await loadQueue();
const due = jobs
.filter((job) => job.status === "scheduled" && Date.parse(job.nextAttemptAt) <= now)
.slice(0, limit);
for (const job of due) job.status = "processing";
await saveQueue(jobs);
return due;
}
export async function completeJob(id: string) {
const jobs = await loadQueue();
const job = jobs.find((item) => item.id === id);
if (job) job.status = "sent";
await saveQueue(jobs);
}
export async function failJob(id: string, error: unknown) {
const jobs = await loadQueue();
const job = jobs.find((item) => item.id === id);
if (!job) return;
job.attempts += 1;
job.lastError = error instanceof Error ? error.message : String(error);
if (job.attempts >= job.maxAttempts) {
job.status = "failed";
} else {
const delayMs = Math.min(15 * 60_000, 2 ** job.attempts * 1000);
job.status = "scheduled";
job.nextAttemptAt = new Date(Date.now() + delayMs).toISOString();
}
await saveQueue(jobs);
}
// scripts/send-lead-magnet.ts
import { enqueueEmail } from "../src/email/queue";
import { leadSchema } from "../src/email/schema";
import { renderLeadMagnetEmail } from "../src/email/templates";
const email = process.env.EMAIL_TO;
if (!email) throw new Error("Set EMAIL_TO before running this script.");
const appUrl = process.env.APP_URL ?? "https://example.com";
const lead = leadSchema.parse({
email,
name: process.env.LEAD_NAME ?? "Reader",
locale: "en",
source: "article",
consentAt: new Date().toISOString(),
tags: ["claude-code", "email-automation"],
});
const message = renderLeadMagnetEmail({
lead,
downloadUrl: `${appUrl}/downloads/claude-code-email-checklist.pdf`,
unsubscribeUrl: `${appUrl}/unsubscribe?email=${encodeURIComponent(lead.email)}`,
});
const id = await enqueueEmail({ message, maxAttempts: 4 });
console.log(`Queued lead magnet email: ${id}`);
// scripts/email-worker.ts
import { claimDueJobs, completeJob, failJob } from "../src/email/queue";
import { createEmailProvider, isRetryable } from "../src/email/provider";
const provider = createEmailProvider();
const jobs = await claimDueJobs(Number(process.env.EMAIL_WORKER_BATCH ?? 3));
for (const job of jobs) {
try {
const result = await provider.send(job.message);
await completeJob(job.id);
console.log(`sent ${job.id} as ${result.providerMessageId}`);
} catch (error) {
await failJob(job.id, error);
const retryable = isRetryable(error) ? "retryable" : "non-retryable";
console.error(`failed ${job.id}: ${retryable}`, error);
}
}
Run it first against an address you control. Do not send to readers until the sender domain is authenticated and the unsubscribe route actually works.
npm install
EMAIL_TO=you@example.com APP_URL=https://example.com npm run lead:send
EMAIL_PROVIDER=resend RESEND_API_KEY=re_xxx npm run email:worker
Normalize Bounces, Unsubscribes, and Analytics
The provider response only tells you whether the API accepted the request. It does not prove that the recipient read the email, clicked the CTA, or wants more messages. Normalize provider webhooks into your own event model.
// src/email/events.ts
import { providerEventSchema, type ProviderEvent } from "./schema";
export function normalizeProviderEvent(payload: unknown): ProviderEvent {
const raw = payload as Record<string, unknown>;
const type = String(raw.type ?? raw.event ?? "delivered");
const email = String(raw.email ?? raw.recipient ?? "");
const mappedType =
type.includes("bounce") ? "bounce" :
type.includes("complaint") || type.includes("spam") ? "complaint" :
type.includes("unsubscribe") ? "unsubscribe" :
type.includes("click") ? "click" :
type.includes("open") ? "open" :
type.includes("defer") ? "deferred" :
"delivered";
return providerEventSchema.parse({
provider: raw.sg_event_id ? "sendgrid" : raw.created_at ? "resend" : "unknown",
type: mappedType,
email: email || undefined,
providerMessageId: String(raw.email_id ?? raw.sg_message_id ?? ""),
reason: typeof raw.reason === "string" ? raw.reason : undefined,
occurredAt: new Date(String(raw.created_at ?? Date.now())).toISOString(),
});
}
export function shouldSuppress(event: ProviderEvent): boolean {
return event.type === "bounce" || event.type === "complaint" || event.type === "unsubscribe";
}
For analytics, do not rely only on opens. Privacy protections and image blocking make open rate noisy. Track download completion, CTA clicks, consultation form starts, replies, unsubscribe rate, bounce rate, and purchases. Use meaningful event names such as lead_magnet_requested, email_cta_click, and consultation_request_started, then connect them to the measurement plan in analytics implementation.
Practical Use Cases
The first use case is a free PDF under technical articles. When a reader requests the checklist, send the download immediately. The next email can explain the most common setup failure, the third can point to a product template, and the fourth can invite a training or consulting conversation. Keep each message focused on one action and include an unsubscribe link every time.
The second use case is product onboarding. If someone buys a Gumroad guide or joins a workshop, the first message should help them start, the second should solve common blockers, and the third should suggest advanced material. Do not turn a receipt into a sales blast. Helping the buyer succeed is usually the strongest upsell.
The third use case is consultation follow-up. A useful follow-up includes meeting notes, agreed next steps, relevant links, a deadline, and a booking or proposal CTA. If the message ignores the actual conversation, it feels like spam even when the person filled out a form.
The fourth use case is low-frequency re-engagement. For inactive but consented readers, send only substantial updates, practical failure stories, or new resources. If clicks and replies do not return, reduce frequency or stop. Domain reputation is more valuable than one extra campaign.
Failure Modes to Catch Early
The first failure is exposing the provider API key in browser code. Keep email sending on the server. A leaked key can be abused to send spam through your account.
The second failure is sending from a domain you have not authenticated. Avoid spoofed From addresses and set up SPF, DKIM, and DMARC for your own domain. Provider dashboards help, but the receiver’s rules still matter.
The third failure is ignoring unsubscribe and bounce events. A suppression list is not optional. People who unsubscribed, complained, or hard-bounced should be excluded from normal campaigns.
The fourth failure is treating rate limits as a reason to retry immediately. Handle 429 and temporary 5xx responses with backoff, send at a stable pace, and watch provider responses. Exact limits vary by account, plan, domain reputation, and recipient provider.
The fifth failure is mixing transactional and promotional intent. Password resets, receipts, and account alerts should stay clear and functional. Put monetization CTAs in messages where the reader gave consent and the context makes sense.
Monetization CTA
The finished system should make the next step obvious without pushing every offer in every email. For ClaudeCodeLab, beginners can start with the free PDF, builders can compare products and templates, and teams can use training and consulting when they need this mapped to a real repository and revenue funnel.
After trying this workflow, the biggest improvement came from designing consent, unsubscribe, bounce handling, and CTA analytics before writing provider-specific code. Start with one lead magnet email, verify send, unsubscribe, bounce, and click events, then expand into onboarding and consultation follow-up once the system is observable.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.