Claude Code 이메일 자동화: 리드 수집부터 수익화까지 Node.js 구현
Claude Code로 리드 마그넷, 동의, 구독 해지, 재시도, 분석까지 이메일 자동화를 구현합니다.
이메일 자동화는 폼 제출 뒤에 한 통을 보내는 기능이 아닙니다. 수익으로 이어지는 시스템은 리드 마그넷을 전달하고, 온보딩 시퀀스를 시작하고, 상담 요청을 후속 처리하고, 동의 기록을 남기고, 구독 해지를 처리하고, 반송 주소를 억제하고, 일시 장애를 재시도하고, 어떤 CTA가 제품 구매나 컨설팅 문의로 이어졌는지 측정해야 합니다.
Claude Code가 이 작업에 잘 맞는 이유는 이메일 기능이 여러 파일과 운영 규칙을 동시에 건드리기 때문입니다. 스키마, 템플릿, 발송 provider adapter, 큐, webhook, 분석 이벤트, 문서가 함께 움직입니다. Masa가 이 사이트의 무료 PDF 흐름을 고쳤을 때 처음의 실수는 Resend 발송 함수부터 만든 것이었습니다. 동의, 해지 URL, bounce 처리, CTA 분석을 나중에 붙이다 보니 작동은 했지만 운영하기 어려웠습니다. 먼저 설계표를 만들고, Claude Code가 수정할 파일을 제한하는 방식이 더 안정적입니다.
이 글은 Resend 스타일 API와 SendGrid 스타일 API를 바꿔 쓸 수 있는 Node.js/TypeScript 구현을 다룹니다. 리드 마그넷 전달, 온보딩, 상담 후속 메일, SPF/DKIM/DMARC 기본, 안전한 아웃리치 경계, rate limit, queue/retry, 템플릿, 분석, 그리고 제품/교육/컨설팅 CTA까지 연결합니다. 함께 보면 좋은 글은 콘텐츠 퍼널 감사, 분석 구현, 쿠키와 동의 관리입니다.
코딩 전에 시스템을 나눈다
리드 마그넷은 이메일 주소와 교환해 제공하는 무료 PDF, 체크리스트, 템플릿입니다. 온보딩은 가입자나 구매자가 실제로 시작하도록 돕는 연속 메일입니다. 상담 follow-up은 미팅 내용, 다음 단계, 제안서, 예약 링크를 보내는 업무 메일입니다.
이 세 가지를 모두 newsletter로 취급하면 동의, 해지, 분석, 문체가 엉킵니다.
| 목표 | 수신자 | 메일 예시 | 수익 경로 | 주의점 |
|---|---|---|---|---|
| 리드 수집 | 무료 PDF를 요청한 독자 | 다운로드 링크, 관련 가이드 | 무료 PDF에서 제품으로 | 동의 시각과 해지 URL 저장 |
| 온보딩 | 구매자, 교육 참가자 | 시작 방법, 체크리스트, 자주 막히는 지점 | 템플릿, 강의, 추가 지원 | 영수증에 과한 홍보를 섞지 않기 |
| 상담 후속 | 문의한 잠재 고객 | 미팅 메모, 제안, 다음 예약 | 교육과 컨설팅 | 실제 대화 맥락 반영 |
| 재참여 | 동의했지만 오래 비활성인 독자 | 실패 사례, 큰 업데이트 | 제품 또는 상담 | 빈도, 반송, 해지율 감시 |
용어도 쉽게 정리합니다. SPF는 “이 서버가 이 도메인으로 메일을 보낼 수 있다”는 DNS 설정입니다. DKIM은 메일이 승인된 발신자에게서 왔고 중간에 바뀌지 않았음을 검증하는 서명입니다. DMARC는 SPF나 DKIM 정렬이 실패했을 때 수신 서버가 어떻게 처리할지 알려주는 정책입니다. Bounce는 배달 실패로 돌아온 메일이고, rate limit은 너무 빠르게 보내거나 평판/한도에 걸려 provider가 요청을 제한하는 상황입니다.
발송 도메인과 인증은 공식 문서를 기준으로 확인해야 합니다. Gmail 수신 요건은 Google email sender guidelines, Resend는 Resend domain management, SendGrid는 Twilio SendGrid domain authentication을 확인하세요. DMARC는 2026년에 RFC 9989로 갱신되어 예전 RFC 7489를 대체합니다. 미국 상업 메일은 FTC의 CAN-SPAM guide도 확인해야 합니다. 이 글은 구현 가이드이며 법률 자문은 아닙니다.
flowchart LR
Visitor["글을 읽는 사람"]
Form["리드 폼"]
Consent["동의 기록"]
Queue["메일 큐"]
Provider["Resend / SendGrid"]
Inbox["받은 편지함"]
Webhook["발송 이벤트"]
Analytics["분석"]
Offer["제품 / 교육 / 컨설팅"]
Visitor --> Form --> Consent --> Queue --> Provider --> Inbox
Provider --> Webhook --> Analytics --> Offer
Inbox --> Offer
Claude Code에 줄 프롬프트
“이메일 자동화 만들어줘”라고만 하면 발송 함수 하나로 끝날 가능성이 큽니다. 목적, 허용 파일, provider 경계, 동의 규칙, 재시도 방식, 검증 조건을 명확히 적습니다.
이 저장소에 이메일 자동화를 구현해 주세요.
목표는 리드 마그넷 전달, 3통의 온보딩 시퀀스, 상담 후속 메일입니다.
제약:
- Node.js 20+와 TypeScript 사용
- Resend 스타일 API와 SendGrid 스타일 API를 바꿀 수 있는 provider adapter 작성
- API key는 서버 환경 변수에서만 사용하고 브라우저에 노출하지 않음
- lead, email job, unsubscribe, provider event schema 작성
- 429와 5xx는 exponential backoff로 재시도
- unsubscribe, complaint, suppression 상태인 주소에는 보내지 않음
- hard bounce가 반복된 주소는 suppression list에 넣음
- text, HTML, unsubscribe URL, 발신자 정보를 포함
- README에 공식 provider 및 인증 문서 링크 추가
- 실행 가능한 script와 focused test 추가
먼저 설계표와 파일 목록을 보여주고, 승인 후 수정하세요.
실행 가능한 최소 구현
아래 예시는 로컬 JSON 파일을 간단한 큐로 사용합니다. 데모에는 충분하지만, 본番에서는 Postgres, Redis, SQS, Cloud Tasks처럼 잠금과 감사 로그를 다룰 수 있는 큐로 바꾸세요.
{
"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("ko"),
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 type Lead = z.infer<typeof leadSchema>;
export type SendMessage = z.infer<typeof sendMessageSchema>;
export type EmailJobInput = z.infer<typeof emailJobSchema>;
// 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();
}
// 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/email-worker.ts
import { claimDueJobs, completeJob, failJob } from "../src/email/queue";
import { createEmailProvider } 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);
console.error(`failed ${job.id}`, error);
}
}
처음에는 자신이 관리하는 테스트 주소로만 보냅니다. 발신 도메인 인증과 구독 해지 라우트가 동작하기 전에는 독자에게 보내지 마세요.
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
Bounce, 해지, 분석을 같은 설계에 넣기
provider API가 성공했다고 해서 실제로 읽혔다는 뜻은 아닙니다. webhook 이벤트를 내부 공통 모델로 바꾸고, bounce, complaint, unsubscribe는 suppression list에 반영해야 합니다.
// src/email/events.ts
import { z } from "zod";
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 function normalizeProviderEvent(payload: unknown) {
const raw = payload as Record<string, unknown>;
const type = String(raw.type ?? raw.event ?? "delivered");
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: String(raw.email ?? raw.recipient ?? "") || 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(),
});
}
분석은 open rate만 보지 않는 편이 좋습니다. 이미지 차단과 개인 정보 보호 기능 때문에 열람률은 흔들립니다. 다운로드 완료, CTA 클릭, 상담 폼 시작, 회신, 해지율, 반송률, 구매를 함께 봅니다. 이벤트 이름은 lead_magnet_requested, email_cta_click, consultation_request_started처럼 의미가 분명한 snake_case로 통일하세요.
실제 사용 사례
첫 번째는 기술 글 하단의 무료 PDF입니다. 독자가 체크리스트를 요청하면 즉시 다운로드 링크를 보내고, 다음 메일에서는 흔한 설정 실패, 그다음은 제품 템플릿, 마지막은 교육이나 컨설팅을 안내합니다. 각 메일에는 하나의 주요 행동과 해지 링크가 있어야 합니다.
두 번째는 제품 구매 후 온보딩입니다. Gumroad 가이드나 워크숍을 산 사람에게는 시작 방법, 흔한 막힘, 고급 활용을 순서대로 보내는 것이 좋습니다. 영수증을 강한 홍보 메일로 바꾸지 마세요. 구매자가 성공하도록 돕는 것이 가장 좋은 업셀입니다.
세 번째는 상담 follow-up입니다. 좋은 메일은 회의 메모, 결정 사항, 다음 준비, 관련 링크, 제안 기한, 예약 CTA를 담습니다. 실제 대화와 맞지 않는 템플릿은 폼을 제출한 사람에게도 스팸처럼 느껴집니다.
네 번째는 비활성 리드 재참여입니다. 동의는 했지만 오래 반응이 없는 독자에게는 큰 업데이트나 실패 사례만 낮은 빈도로 보냅니다. 클릭과 회신이 돌아오지 않으면 줄이거나 멈추세요. 도메인 평판이 단기 캠페인보다 중요합니다.
실패 모드
첫 번째 실패는 API key를 브라우저 코드에 넣는 것입니다. 발송 key는 서버에서만 사용해야 합니다.
두 번째 실패는 인증되지 않은 도메인에서 보내는 것입니다. 자신의 도메인에 SPF, DKIM, DMARC를 설정하고, provider 대시보드와 수신자 응답을 함께 확인하세요.
세 번째 실패는 해지와 bounce를 무시하는 것입니다. unsubscribe, complaint, hard bounce는 일반 캠페인에서 제외해야 합니다.
네 번째 실패는 rate limit 이후 즉시 반복 발송하는 것입니다. 429와 임시 5xx에는 backoff를 적용하고, 안정적인 속도로 보냅니다. 한도는 계정, 요금제, 평판, 수신 provider에 따라 달라집니다.
다섯 번째 실패는 트랜잭션 메일과 홍보 메일을 섞는 것입니다. 비밀번호 재설정, 영수증, 계정 알림은 기능에 집중하고, 제품/교육/상담 CTA는 동의와 맥락이 있는 메일에 넣습니다.
수익화 CTA
완성 기준은 “메일이 발송됐다”가 아니라 독자가 다음 행동을 자연스럽게 고를 수 있는가입니다. ClaudeCodeLab에서는 초보자는 무료 PDF에서 시작하고, 실무자는 제품과 템플릿을 보고, 팀 도입이나 수익 흐름 설계가 필요하면 교육과 컨설팅으로 이어집니다.
이 흐름을 실제로 적용해 보니 가장 큰 개선은 provider 코드가 아니라 동의, 해지, bounce, CTA 분석을 처음부터 설계한 것이었습니다. 먼저 리드 마그넷 한 통만 만들고 발송, 해지, bounce, 클릭을 관찰한 뒤 온보딩과 상담 follow-up으로 확장하세요.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.