Otomasi Email dengan Claude Code: dari Lead Capture ke Monetisasi
Bangun otomasi email dengan Claude Code: lead magnet, consent, unsubscribe, retry, analytics, dan revenue.
Otomasi email bukan sekadar mengirim satu pesan setelah form dikirim. Sistem yang bisa mendukung monetisasi harus mengirim lead magnet, memulai onboarding, menindaklanjuti konsultasi, menyimpan consent, memproses unsubscribe, menghentikan pengiriman ke alamat bounce, retry saat gagal sementara, dan mengukur CTA mana yang menghasilkan pembelian produk, training, atau konsultasi.
Claude Code cocok untuk pekerjaan ini karena fitur email menyentuh banyak lapisan sekaligus: schema, template, provider adapter, queue, webhook, analytics event, dan dokumentasi. Saat Masa memperbaiki funnel PDF gratis di situs ini, kesalahan awalnya adalah membuat fungsi kirim Resend terlebih dahulu. Kodenya berjalan, tetapi consent, unsubscribe URL, bounce handling, dan CTA analytics menjadi tambalan. Lebih baik minta Claude Code membuat design table dan daftar file dulu, lalu batasi perubahan hanya pada file yang disetujui.
Artikel ini membuat implementasi Node.js/TypeScript yang tidak terkunci ke satu provider. Kamu bisa memakai API bergaya Resend atau SendGrid. Kita akan membahas lead magnet delivery, onboarding, consultation follow-up, dasar SPF/DKIM/DMARC, batas outreach yang aman, rate limit, queue/retry, template, analytics, dan CTA menuju produk, training, atau consulting. Untuk konteks funnel, baca juga content funnel audit, analytics implementation, dan cookie/consent management.
Rancang sebelum coding
Lead magnet adalah resource gratis seperti PDF, checklist, atau template yang diberikan sebagai imbalan alamat email. Onboarding adalah rangkaian email yang membantu seseorang mulai memakai produk, course, atau tool setelah daftar atau membeli. Consultation follow-up adalah email operasional berisi catatan meeting, langkah berikutnya, proposal, atau link booking.
Jangan masukkan semuanya ke label newsletter umum. Consent, tone, metrik, dan risikonya berbeda.
| Tujuan | Penerima | Contoh email | Jalur revenue | Risiko |
|---|---|---|---|---|
| Lead capture | Pembaca yang meminta PDF | Link download dan guide terkait | PDF gratis ke produk | Simpan consent dan unsubscribe URL |
| Onboarding | Pembeli atau peserta training | Panduan mulai, checklist, blocker umum | Template, course, support | Jangan ubah receipt jadi promosi agresif |
| Follow-up konsultasi | Lead berkualitas | Catatan, proposal, jadwal berikutnya | training dan consulting | Pakai konteks percakapan asli |
| Re-engagement | Pembaca consent tapi tidak aktif | Cerita kegagalan atau update besar | Produk atau konsultasi | Pantau frekuensi, bounce, opt-out |
Istilah teknis perlu dijelaskan sederhana. SPF adalah record DNS yang menyatakan server mana yang boleh mengirim email untuk domain kamu. DKIM menambahkan signature agar penerima bisa memverifikasi email itu sah dan tidak diubah. DMARC memberi tahu kebijakan saat SPF atau DKIM tidak selaras. Bounce berarti email gagal terkirim. Rate limit berarti provider memperlambat atau menolak request karena kamu mengirim terlalu cepat, mencapai kuota, atau reputasi perlu dijaga.
Gunakan dokumen resmi sebagai sumber. Untuk Gmail, lihat Google email sender guidelines. Untuk provider, mulai dari Resend domain management atau Twilio SendGrid domain authentication. DMARC diperbarui pada 2026 lewat RFC 9989 yang menggantikan RFC 7489. Untuk email komersial ke AS, cek panduan CAN-SPAM dari FTC. Ini panduan implementasi, bukan nasihat hukum.
flowchart LR
Visitor["Pembaca"]
Form["Form lead"]
Consent["Log consent"]
Queue["Queue email"]
Provider["Resend / SendGrid"]
Inbox["Inbox"]
Webhook["Event delivery"]
Analytics["Analytics"]
Offer["Produk / training / consulting"]
Visitor --> Form --> Consent --> Queue --> Provider --> Inbox
Provider --> Webhook --> Analytics --> Offer
Inbox --> Offer
Prompt untuk Claude Code
Prompt yang terlalu umum biasanya hanya menghasilkan fungsi kirim. Prompt yang baik menentukan tujuan, batas, dan cara verifikasi.
Implementasikan otomasi email di repository ini.
Tujuan: lead magnet delivery, sequence onboarding 3 email, consultation follow-up.
Constraint:
- Pakai Node.js 20+ dan TypeScript.
- Buat provider adapter yang bisa switch antara API gaya Resend dan SendGrid.
- API key hanya di env server, jangan expose ke browser.
- Buat schema untuk lead, email job, unsubscribe, provider event.
- Retry 429 dan 5xx dengan exponential backoff.
- Jangan kirim ke alamat unsubscribed, complaint, atau suppression.
- Hard bounce berulang masuk suppression list.
- Sertakan text body, HTML body, unsubscribe URL, dan sender detail yang jelas.
- Tambahkan link official provider dan authentication docs di README.
- Tambahkan script runnable dan test yang fokus.
Pertama tampilkan design table dan daftar file. Tunggu approval sebelum edit.
Starter yang bisa dicopy
Contoh ini memakai file JSON lokal sebagai queue kecil agar mudah dijalankan. Untuk produksi, ganti dengan Postgres, Redis, SQS, Cloud Tasks, atau queue durable lain dengan lock dan audit log.
{
"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("id"),
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 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);
}
}
Coba dulu dengan alamat yang kamu kontrol. Jangan kirim ke pembaca sungguhan sebelum domain terautentikasi dan route unsubscribe benar-benar bekerja.
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, unsubscribe, dan analytics
Response sukses dari provider hanya berarti request diterima. Itu tidak membuktikan email dibaca, CTA diklik, atau penerima ingin pesan berikutnya. Normalisasi webhook ke event internal dan update suppression list untuk hard bounce, complaint, serta unsubscribe.
// 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(),
});
}
Jangan hanya mengandalkan open rate. Image blocking dan fitur privasi membuat angka itu tidak stabil. Ukur download completion, CTA click, consultation form start, reply, unsubscribe rate, bounce rate, dan purchase. Pakai nama event seperti lead_magnet_requested, email_cta_click, dan consultation_request_started.
Use case praktis
Pertama, PDF gratis di bawah artikel teknis. Kirim download segera, lalu email tentang kegagalan setup paling umum, lalu template produk, lalu ajakan training atau consulting. Setiap email harus punya satu aksi utama dan link unsubscribe.
Kedua, onboarding setelah pembelian. Pembeli guide atau peserta workshop butuh arahan mulai, solusi blocker, dan penggunaan lanjutan. Membantu pembeli berhasil biasanya lebih kuat daripada menjadikan receipt sebagai promosi.
Ketiga, consultation follow-up. Email yang baik berisi catatan meeting, keputusan, persiapan berikutnya, link relevan, deadline, dan CTA booking atau proposal. Jika tidak mencerminkan percakapan nyata, email yang diminta pun terasa seperti spam.
Keempat, re-engagement dengan frekuensi rendah. Untuk pembaca yang consent tetapi tidak aktif, kirim hanya update besar, cerita kegagalan yang berguna, atau resource baru. Jika click dan reply tidak kembali, turunkan frekuensi atau hentikan.
Kegagalan yang perlu dihindari
Kegagalan pertama adalah menaruh API key di browser. Pengiriman email harus tetap di server.
Kegagalan kedua adalah mengirim dari domain yang belum diautentikasi. Siapkan SPF, DKIM, dan DMARC untuk domain sendiri.
Kegagalan ketiga adalah mengabaikan unsubscribe dan bounce. Unsubscribe, complaint, dan hard bounce harus keluar dari campaign normal.
Kegagalan keempat adalah retry langsung setelah rate limit. Tangani 429 dan 5xx sementara dengan backoff. Batas pasti bergantung pada account, plan, reputasi, dan provider penerima.
Kegagalan kelima adalah mencampur email transaksional dan promosi. Password reset, receipt, dan account alert harus jelas. CTA komersial ditempatkan di email yang punya consent dan konteks.
CTA monetisasi
Sistem selesai ketika pembaca bisa memilih langkah berikutnya dengan natural. Di ClaudeCodeLab, pemula bisa mulai dari PDF gratis, builder bisa melihat produk dan template, dan tim bisa memakai training dan consulting untuk menerapkannya ke repository nyata.
Setelah mencoba workflow ini, peningkatan terbesar bukan dari kode provider, tetapi dari merancang consent, unsubscribe, bounce, dan CTA analytics sejak awal. Mulai dengan satu email lead magnet, verifikasi send, unsubscribe, bounce, dan click, lalu perluas ke onboarding dan consultation follow-up.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Workflow Obsidian ke CLAUDE.md untuk Claude Code
Ubah catatan kerja Obsidian menjadi operating note CLAUDE.md agar konteks tidak dijelaskan ulang.
Claude Code Revenue CTA Routing: dari artikel ke PDF, Gumroad, dan konsultasi
Workflow Claude Code untuk mengarahkan pembaca ke PDF gratis, Gumroad, atau konsultasi sesuai intent.
Aturan handoff tim Claude Code: bukti review, permission, rollback, dan jalur revenue
Format handoff Claude Code untuk tim: bukti, permission rule, rollback, PDF gratis, Gumroad, dan konsultasi.