Cara Aman Mengimplementasikan Email SendGrid dengan Claude Code
Implementasi SendGrid dengan Claude Code: sender terverifikasi, Mail Send API, retry, log, dan deliverability.
SendGrid adalah layanan cloud untuk mengirim email aplikasi melalui API. Use case yang umum adalah konfirmasi form kontak, onboarding setelah registrasi, laporan harian, notifikasi transaksional, dan follow-up sales yang memiliki dasar hubungan serta jalur opt-out yang jelas.
Risikonya, kode email terlihat terlalu sederhana. Jika Anda hanya meminta Claude Code “kirim email dengan SendGrid”, kemungkinan besar Anda mendapat API call yang berjalan, tetapi belum tentu ada sender terverifikasi, perlindungan API key, pencegahan duplicate send saat retry, bounce handling, spam complaint, provider log, atau aturan opt-out. Email yang sudah terkirim tidak bisa ditarik kembali. Karena itu, batas operasional harus ditentukan sebelum fungsi fetch.
Panduan ini memakai dokumentasi resmi Twilio SendGrid untuk v3 Mail Send API, halaman Validation Error, dan situs SendGrid sebagai rujukan. Anda akan mendapatkan script Node.js yang bisa langsung disalin, aman secara default: dry-run tanpa --send, validasi payload, mode sandbox, retry hanya untuk error sementara, dan log lokal sebagai contoh idempotency guard.
Untuk konteks tambahan, baca juga otomasi email dengan Claude Code, pengembangan API, manajemen environment variable, dan praktik keamanan.
Dasar SendGrid Sebelum Menulis Kode
Mail Send API menerima JSON di POST https://api.sendgrid.com/v3/mail/send dengan header Authorization: Bearer SENDGRID_API_KEY. Call-nya sederhana, tetapi produksi membutuhkan checklist di sekitarnya.
| Item | Arti sederhana | Yang perlu dicek |
|---|---|---|
| Verified sender | SendGrid mengonfirmasi alamat from boleh mengirim | Single Sender untuk test kecil, Domain Authentication untuk produksi |
| Domain authentication | DNS membuktikan domain Anda boleh mengirim lewat SendGrid | SPF/DKIM sudah verified sebelum traffic real |
| API key | Secret server untuk memanggil SendGrid | Hanya di environment variable server, jangan di browser atau Git |
personalizations | Data per penerima seperti to, subject, custom args, atau template data | Satu penerima per personalization agar daftar email tidak bocor |
| Suppression | Alamat yang tidak boleh dikirim karena bounce, complaint, atau unsubscribe | Cek list internal sebelum memanggil SendGrid |
| Provider log | HTTP status, response body, dan x-message-id | Simpan untuk support, audit, dan pencegahan duplikasi |
SPF adalah DNS record yang memberi tahu server mana yang boleh mengirim atas nama domain Anda. DKIM adalah tanda tangan agar penerima bisa memverifikasi pesan diotorisasi dan tidak diubah. DMARC adalah kebijakan ketika SPF atau DKIM tidak selaras. Untuk pemula, model mentalnya cukup: sender authentication adalah identitas yang menopang deliverability.
Jangan mulai dengan alamat Gmail acak di from. Untuk proof of concept lokal, gunakan Single Sender yang sudah diverifikasi. Untuk produksi, autentikasi domain sendiri dan kirim dari alamat produk, support, atau tim yang nyata. Banyak validation error muncul dari from yang salah, personalizations yang tidak lengkap, content yang hilang, atau template yang keliru.
Empat Use Case Praktis
Jangan menyatukan semua email dalam helper sendMail generik. Setiap alur punya consent, frekuensi, nada, risiko, dan kebutuhan log yang berbeda.
| Use case | Contoh | Guardrail yang dibutuhkan |
|---|---|---|
| Email form kontak | Konfirmasi ke pengunjung dan notifikasi ke tim | Escape input user, pisahkan email admin dan visitor |
| Onboarding transaksional | Registrasi selesai, panduan login pertama, instruksi pembelian | Tetap sesuai ekspektasi, jangan disisipi promosi agresif |
| Email laporan harian | Revenue, error, booking, progres kursus | Pakai idempotency key agar retry tidak terlihat sebagai laporan ganda |
| Sales atau outreach | Follow-up meeting, proposal, resource yang dijanjikan | Sertakan opt-out, identitas pengirim, suppression, dan cek kepatuhan lokal |
Outreach membutuhkan perhatian khusus. Bisa mengirim secara teknis bukan berarti selalu boleh atau layak mengirim. Aturan berubah berdasarkan negara, relasi, B2B/B2C, dan jenis pesan. Artikel ini adalah panduan implementasi, bukan nasihat hukum. Minimal, jelaskan alasan menghubungi, identitas pengirim, dan cara opt-out yang benar-benar berfungsi.
flowchart LR
App["App / perubahan Claude Code"]
Validate["Validasi payload"]
Log["Send log dan idempotency key"]
SendGrid["SendGrid Mail Send API"]
Inbox["Inbox"]
Events["Bounce / Spam / Unsubscribe"]
Suppression["Suppression list"]
App --> Validate --> Log --> SendGrid --> Inbox
SendGrid --> Events --> Suppression
Suppression --> Validate
Script Node.js yang Bisa Disalin
Script berikut berjalan di Node.js 20 atau lebih baru dan tidak membutuhkan dependency. Default-nya dry-run: payload dicetak, log ditulis, tetapi SendGrid tidak dipanggil. Gunakan --send untuk call nyata dan --send --sandbox agar SendGrid memvalidasi request tanpa mengirim email.
// sendgrid-safe-send.mjs
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
const ENDPOINT = process.env.SENDGRID_API_BASE ?? "https://api.sendgrid.com/v3/mail/send";
const LOG_PATH = process.env.SENDGRID_SEND_LOG ?? ".sendgrid-send-log.json";
const DRY_RUN = !process.argv.includes("--send");
const SANDBOX = process.argv.includes("--sandbox");
const MAX_ATTEMPTS = Number.parseInt(process.env.SENDGRID_MAX_ATTEMPTS ?? "3", 10);
const recipient = {
email: process.env.MAIL_TO ?? "recipient@example.com",
name: process.env.MAIL_TO_NAME ?? "Test Recipient",
};
const message = {
from: {
email: process.env.MAIL_FROM ?? "verified-sender@example.com",
name: process.env.MAIL_FROM_NAME ?? "ClaudeCodeLab Demo",
},
reply_to: {
email: process.env.MAIL_REPLY_TO ?? process.env.MAIL_FROM ?? "verified-sender@example.com",
},
personalizations: [
{
to: [recipient],
custom_args: {
use_case: process.env.MAIL_USE_CASE ?? "dry_run_demo",
},
},
],
subject: process.env.MAIL_SUBJECT ?? `SendGrid dry-run test for ${recipient.name}`,
content: [
{
type: "text/plain",
value: `Hello ${recipient.name},\n\nThis is a safe SendGrid test from Claude Code.\n`,
},
{
type: "text/html",
value: `<p>Hello ${escapeHtml(recipient.name)},</p><p>This is a safe SendGrid test from Claude Code.</p>`,
},
],
categories: ["claude-code-demo"],
mail_settings: {
sandbox_mode: { enable: SANDBOX },
},
};
validatePayload(message);
const idempotencyKey = makeIdempotencyKey(message);
for (const personalization of message.personalizations) {
personalization.custom_args = {
...(personalization.custom_args ?? {}),
idempotency_key: idempotencyKey,
};
}
await sendWithRetry(message, idempotencyKey);
function validatePayload(payload) {
if (!Number.isInteger(MAX_ATTEMPTS) || MAX_ATTEMPTS < 1 || MAX_ATTEMPTS > 5) {
throw new Error("SENDGRID_MAX_ATTEMPTS must be an integer from 1 to 5.");
}
assertEmail(payload.from?.email, "from.email");
if (!DRY_RUN && payload.from.email.endsWith("@example.com")) {
throw new Error("Set MAIL_FROM to a verified SendGrid sender before using --send.");
}
if (!Array.isArray(payload.personalizations) || payload.personalizations.length === 0) {
throw new Error("personalizations must contain at least one recipient.");
}
for (const [index, personalization] of payload.personalizations.entries()) {
if (!Array.isArray(personalization.to) || personalization.to.length !== 1) {
throw new Error(`personalizations[${index}].to must contain exactly one recipient.`);
}
assertEmail(personalization.to[0]?.email, `personalizations[${index}].to[0].email`);
}
if (!payload.subject && !payload.template_id) {
throw new Error("Provide a subject or a SendGrid template_id.");
}
const hasContent = Array.isArray(payload.content)
&& payload.content.some((item) => typeof item.value === "string" && item.value.trim());
if (!hasContent && !payload.template_id) {
throw new Error("Provide text/html content or a SendGrid template_id.");
}
}
function assertEmail(value, field) {
if (typeof value !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new Error(`${field} must be a valid email address.`);
}
}
function makeIdempotencyKey(payload) {
const stableEnvelope = {
from: payload.from.email.toLowerCase(),
to: payload.personalizations.map((item) => item.to[0].email.toLowerCase()),
subject: payload.subject,
content: payload.content?.map((item) => item.value),
useCase: payload.personalizations.map((item) => item.custom_args?.use_case ?? ""),
};
return createHash("sha256").update(JSON.stringify(stableEnvelope)).digest("hex").slice(0, 32);
}
async function sendWithRetry(payload, idempotencyKey) {
const log = await readJsonLog();
const previous = log[idempotencyKey];
if (previous?.status === "accepted") {
console.log(`Already accepted by SendGrid. idempotencyKey=${idempotencyKey}`);
return;
}
if (previous?.status === "pending") {
throw new Error(`A send is already pending. idempotencyKey=${idempotencyKey}`);
}
if (DRY_RUN) {
log[idempotencyKey] = {
status: "dry-run",
updatedAt: new Date().toISOString(),
to: payload.personalizations.map((item) => item.to[0].email),
};
await writeJsonLog(log);
console.log("Dry run only. Add --send to call SendGrid.");
console.log(JSON.stringify({ idempotencyKey, payload }, null, 2));
return;
}
const apiKey = process.env.SENDGRID_API_KEY;
if (!apiKey) {
throw new Error("SENDGRID_API_KEY is required when using --send.");
}
log[idempotencyKey] = { status: "pending", updatedAt: new Date().toISOString() };
await writeJsonLog(log);
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const responseBody = await response.text();
const providerMessageId = response.headers.get("x-message-id");
if (response.status === 202) {
log[idempotencyKey] = {
status: "accepted",
statusCode: response.status,
providerMessageId,
updatedAt: new Date().toISOString(),
};
await writeJsonLog(log);
console.log(`Accepted by SendGrid. idempotencyKey=${idempotencyKey}`);
return;
}
const retryable = response.status === 429 || response.status >= 500;
log[idempotencyKey] = {
status: retryable && attempt < MAX_ATTEMPTS ? "retrying" : "failed",
statusCode: response.status,
responseBody: responseBody.slice(0, 2000),
attempt,
updatedAt: new Date().toISOString(),
};
await writeJsonLog(log);
if (!retryable || attempt === MAX_ATTEMPTS) {
throw new Error(`SendGrid request failed with HTTP ${response.status}: ${responseBody}`);
}
await sleep(Math.min(1000 * 2 ** (attempt - 1), 8000));
}
}
async function readJsonLog() {
if (!existsSync(LOG_PATH)) return {};
return JSON.parse(await readFile(LOG_PATH, "utf8"));
}
async function writeJsonLog(log) {
await writeFile(LOG_PATH, `${JSON.stringify(log, null, 2)}\n`, "utf8");
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
Jalankan dry-run terlebih dahulu. Di Windows PowerShell:
node .\sendgrid-safe-send.mjs
$env:SENDGRID_API_KEY="SG.xxxxx"
$env:MAIL_FROM="verified@example.com"
$env:MAIL_TO="you@example.net"
node .\sendgrid-safe-send.mjs --send --sandbox
node .\sendgrid-safe-send.mjs --send
Di macOS atau Linux:
SENDGRID_API_KEY="SG.xxxxx" MAIL_FROM="verified@example.com" MAIL_TO="you@example.net" node sendgrid-safe-send.mjs --send --sandbox
Log JSON lokal ini hanya untuk belajar. Di produksi, pindahkan ide yang sama ke Postgres, Redis, SQS, Cloud Tasks, atau queue lain yang durable. Pasang unique constraint pada idempotency_key dan pisahkan status provider dari status bisnis.
Prompt untuk Claude Code
Prompt yang baik tidak hanya meminta kode, tetapi juga batas operasional.
Tambahkan pengiriman email SendGrid ke repository ini.
Workflow-nya adalah konfirmasi form kontak, onboarding registrasi, laporan harian, dan follow-up sales.
Constraint:
- Gunakan SendGrid Mail Send API v3.
- API key hanya dibaca dari environment variable server SENDGRID_API_KEY.
- Semua script default dry-run dan hanya mengirim dengan --send.
- Gunakan tepat satu recipient per personalization agar daftar penerima tidak bocor.
- Retry hanya 429 dan 5xx dengan exponential backoff.
- Cek unsubscribe, bounce, dan spam complaint suppression sebelum mengirim.
- Simpan provider response, HTTP status, x-message-id, dan idempotency key.
- Email outreach harus memiliki jalur opt-out.
- Tautkan dokumentasi resmi SendGrid di README.
Pertama tampilkan tabel desain dan daftar file. Tunggu approval sebelum mengedit.
Dengan prompt seperti ini, Claude Code harus memikirkan consent, suppression, logs, retry, dan scope file. Ini juga lebih aman ketika beberapa worker mengedit repository yang sama.
Kegagalan yang Sering Terjadi
| Kegagalan | Dampak | Pencegahan |
|---|---|---|
| API key bocor | Orang lain bisa mengirim lewat akun Anda dan merusak reputasi | Ignore .env, jalankan secret scanning, rotasi key segera |
| Sender belum verified | Error 400, blocking, atau inbox placement buruk | Verifikasi Single Sender atau autentikasi domain |
| Retry mengirim duplikat | Report, receipt, atau follow-up yang sama tiba berkali-kali | Cek send log dan idempotency key sebelum provider call |
| Outreach tanpa opt-out | Complaint dan risiko hukum meningkat | Sertakan opt-out, identitas perusahaan, dan alasan kirim |
| Mengirim terlalu cepat | Rate limit dan reputasi domain turun | Mulai dari volume kecil, pantau bounce dan complaint |
| Tidak menyimpan provider response | Support tidak bisa menjelaskan kejadian | Simpan status, body, x-message-id, dan hash recipient |
| Membuka daftar penerima | User melihat email user lain | Satu recipient per personalization |
202 Accepted dari SendGrid bukan bukti email sudah masuk inbox. Artinya SendGrid menerima request untuk diproses. Untuk operasi nyata, tetap pantau bounce, block, spam report, dan unsubscribe events.
Deliverability dan CTA
Deliverability tidak hanya ditentukan DNS. Sender authentication, ekspektasi penerima, volume kirim, kejelasan isi, riwayat bounce, complaint rate, dan pengalaman unsubscribe semuanya berpengaruh. Minimal pantau sent, accepted, bounce, blocked, spam report, dan unsubscribe.
Dalam funnel seperti ClaudeCodeLab, CTA harus sesuai konteks. Konfirmasi form kontak bisa mengarah ke artikel berguna. Onboarding bisa memberi checklist atau template. Laporan harian sebaiknya tetap operasional. Follow-up sales hanya cocok mengajak konsultasi jika relasinya jelas. Untuk penerapan di repository nyata, halaman training dan konsultasi Claude Code dapat membantu merancang SendGrid setup, environment variable, secret scanning, suppression, dan log.
Hasil Verifikasi Praktis
Saat Masa menguji contoh ini secara lokal, keputusan paling berguna adalah menjadikan dry-run sebagai default. Tanpa flag, script hanya mencetak payload dan menulis log lokal. Jika MAIL_FROM masih @example.com, penggunaan --send berhenti sebelum API call. Dengan --send --sandbox, SendGrid dapat memvalidasi bentuk request tanpa mengirim email. Pada proyek nyata, log lokal ini sebaiknya diganti dengan queue berbasis database yang memiliki unique constraint idempotency dan checks untuk bounce, spam complaint, serta unsubscribe sebelum setiap pengiriman.
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.