Como implementar e-mail SendGrid com Claude Code em segurança
Implemente SendGrid com Claude Code: remetente verificado, Mail Send API, retries, logs e entregabilidade.
SendGrid é um serviço em nuvem para enviar e-mails de aplicação por API. Ele serve para confirmações de formulário de contato, onboarding após cadastro, relatórios diários, notificações transacionais e follow-up comercial quando há contexto legítimo e uma opção clara de opt-out.
O problema é que o código parece simples demais. Se você pedir ao Claude Code apenas “envie e-mail com SendGrid”, provavelmente receberá uma chamada API que funciona, mas pode faltar remetente verificado, proteção da API key, prevenção de envio duplicado em retry, tratamento de bounce, reclamação de spam, logs do provedor e regras de cancelamento. E-mail enviado não volta. Por isso, a fronteira operacional deve vir antes do fetch.
Este guia usa como base a documentação oficial da Twilio SendGrid para a v3 Mail Send API, a página de erros de validação e o site do SendGrid. Você terá um script Node.js copiável e seguro por padrão: dry-run sem --send, validação de payload, modo sandbox, retry apenas para falhas temporárias e log local como exemplo de guarda de idempotência.
Para completar o contexto, veja também automação de e-mail com Claude Code, desenvolvimento de API, gestão de variáveis de ambiente e boas práticas de segurança.
Fundamentos do SendGrid antes do código
A API Mail Send recebe JSON em POST https://api.sendgrid.com/v3/mail/send com o cabeçalho Authorization: Bearer SENDGRID_API_KEY. A chamada é simples, mas produção exige preparação.
| Item | Significado prático | O que verificar |
|---|---|---|
| Remetente verificado | SendGrid confirma que o endereço from pode enviar | Single Sender em testes, Domain Authentication em produção |
| Autenticação de domínio | DNS prova que seu domínio pode enviar via SendGrid | SPF/DKIM verificados antes do tráfego real |
| API key | Segredo usado pelo servidor para chamar o SendGrid | Somente no servidor, nunca no navegador ou no Git |
personalizations | Dados por destinatário: to, assunto, custom args ou dados de template | Um destinatário por personalization para não expor listas |
| Suppression | Endereços bloqueados por bounce, reclamação ou descadastro | Consultar sua própria lista antes de chamar o SendGrid |
| Log do provedor | Status HTTP, corpo da resposta e x-message-id | Guardar dados suficientes para suporte e anti-duplicação |
SPF é um registro DNS que informa quais servidores podem enviar por seu domínio. DKIM assina a mensagem para que o destinatário verifique que ela foi autorizada e não alterada. DMARC define a política quando SPF ou DKIM não alinham. Para começar, basta a ideia principal: autenticação de remetente é o documento de identidade da entregabilidade.
Não coloque um Gmail aleatório no from. Para prova local, use Single Sender verificado. Em produção, autentique o domínio e envie por um endereço real de produto, suporte ou equipe. Muitos erros de validação surgem de from inválido, personalizations incorretas, conteúdo ausente ou template mal configurado.
Quatro casos de uso práticos
Não esconda todos os e-mails em uma função genérica sendMail. Cada fluxo tem consentimento, frequência, tom, risco e log diferentes.
| Caso de uso | Exemplo | Proteção necessária |
|---|---|---|
| Formulário de contato | Confirmação ao visitante e aviso à equipe | Escapar entrada do usuário, separar e-mail interno e externo |
| Onboarding transacional | Cadastro concluído, primeiro acesso, instruções de compra | Manter o conteúdo esperado e útil, sem marketing agressivo |
| Relatório diário | Vendas, erros, reservas, progresso de curso | Usar idempotency key para retry não parecer duplicado |
| Vendas ou outreach | Follow-up de reunião, proposta, material prometido | Incluir opt-out, identidade do remetente e suppression |
Outreach precisa de cuidado extra. Conseguir enviar tecnicamente não significa que o envio é apropriado. Regras variam por país, relação prévia, B2B/B2C e tipo de mensagem. Este artigo é guia de implementação, não aconselhamento jurídico. No mínimo, explique o motivo do contato, identifique o remetente e forneça uma forma funcional de opt-out.
flowchart LR
App["App / mudança do Claude Code"]
Validate["Validação do payload"]
Log["Log e idempotency key"]
SendGrid["SendGrid Mail Send API"]
Inbox["Caixa de entrada"]
Events["Bounce / Spam / Unsubscribe"]
Suppression["Lista de suppression"]
App --> Validate --> Log --> SendGrid --> Inbox
SendGrid --> Events --> Suppression
Suppression --> Validate
Script Node.js copiável
O script abaixo roda em Node.js 20 ou superior e não usa dependências. Por padrão ele faz dry-run: imprime o payload, grava o log e não chama o SendGrid. Use --send para chamada real e --send --sandbox para o SendGrid validar a requisição sem entregar o e-mail.
// 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("'", "'");
}
Comece pelo dry-run. No 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
No macOS ou Linux:
SENDGRID_API_KEY="SG.xxxxx" MAIL_FROM="verified@example.com" MAIL_TO="you@example.net" node sendgrid-safe-send.mjs --send --sandbox
O log JSON local é propositalmente simples. Em produção, leve a mesma ideia para Postgres, Redis, SQS, Cloud Tasks ou outra fila durável. Coloque restrição única em idempotency_key e separe status do provedor do status de negócio.
Prompt para Claude Code
Um bom prompt pede fronteiras operacionais, não só código.
Adicione envio de e-mail com SendGrid a este repositório.
Os fluxos são confirmação de formulário de contato, onboarding de cadastro, relatórios diários e follow-up comercial.
Restrições:
- Usar SendGrid Mail Send API v3.
- Ler a API key apenas da variável de ambiente do servidor SENDGRID_API_KEY.
- Todos os scripts devem ser dry-run por padrão e enviar somente com --send.
- Usar exatamente um destinatário por personalization para não expor listas.
- Repetir apenas 429 e 5xx com exponential backoff.
- Verificar unsubscribe, bounce e spam complaint antes do envio.
- Salvar provider response, HTTP status, x-message-id e idempotency key.
- E-mails de outreach precisam ter caminho de opt-out.
- Linkar a documentação oficial do SendGrid no README.
Primeiro devolva a tabela de design e a lista de arquivos. Aguarde aprovação antes de editar.
Assim o Claude Code precisa considerar consentimento, suppression, logs, retries e escopo de arquivos. Isso também reduz conflito quando outras pessoas trabalham no mesmo repositório.
Falhas comuns
| Falha | Consequência | Prevenção |
|---|---|---|
| Vazamento da API key | Terceiros podem enviar pela sua conta e prejudicar reputação | Ignorar .env, escanear secrets e rotacionar imediatamente |
| Remetente não verificado | Erros 400, bloqueios ou pior inbox placement | Verificar Single Sender ou autenticar domínio |
| Retry duplicado | O mesmo relatório, recibo ou follow-up chega mais de uma vez | Usar send log e idempotency key antes do provider |
| Outreach sem opt-out | Aumentam reclamações e risco legal | Incluir descadastro, identidade e motivo do envio |
| Enviar rápido demais | Rate limits e problemas de reputação | Começar com pouco volume e observar bounces e complaints |
| Não salvar resposta do provedor | Suporte não consegue explicar o incidente | Guardar status, body, x-message-id e hash do destinatário |
| Expor lista de destinatários | Usuários veem e-mails de outras pessoas | Um destinatário por personalization |
Um 202 Accepted do SendGrid não prova que a mensagem chegou à caixa de entrada. Ele diz apenas que o SendGrid aceitou a requisição para processamento. Para operação real, acompanhe bounce, block, spam report e unsubscribe depois do envio.
Entregabilidade e CTA
Entregabilidade não depende só de DNS. Expectativa do destinatário, frequência, clareza do conteúdo, histórico de bounce, taxa de reclamação e facilidade de descadastro também contam. No mínimo, monitore enviados, accepted, bounces, blocked, spam reports e unsubscribes.
Em um funil no estilo ClaudeCodeLab, o CTA deve respeitar o contexto. Uma confirmação de contato pode apontar para um artigo útil. Onboarding pode oferecer checklist ou template. Relatório diário deve continuar operacional. Follow-up comercial só deve convidar para conversa quando a relação justificar. Para aplicar em um repositório real, a página de treinamento e consultoria Claude Code pode cobrir SendGrid, variáveis de ambiente, revisão de segurança, suppression e logs.
Resultado da verificação prática
Quando Masa testou este exemplo, a escolha mais útil foi tornar dry-run o padrão. Sem flags, o script apenas imprimiu o payload e gravou o log local. Com --send e MAIL_FROM em @example.com, ele parou antes da chamada API. Com --send --sandbox, o SendGrid validou o formato da requisição sem entregar e-mail. Em projetos reais, esse log local deve virar uma fila em banco com restrição única de idempotência e checks de bounce, spam complaint e unsubscribe antes de cada envio.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.