Docs/Sicurezza
// sicurezza
Limiti di invio email (Resend)
Le email che partono dal tuo prodotto costano soldi e reputazione. Un endpoint aperto che manda email è la cosa più facile da abusare che esista: chi la trova non deve nemmeno bucare niente, gli basta chiamarla in loop. Qui vediamo com'è cablato l'invio in ZeroToShip, quali limiti applica Resend, e quali limiti conviene metterti da solo.
Come parte un'email in questo repo
Tutto passa da un unico file, libs/resend.ts. Il client Resend viene creato in modo lazy e la funzione lancia subito se la chiave non c'è:
function getResend(): Resend {
if (!process.env.RESEND_API_KEY) {
throw new Error("RESEND_API_KEY is not set");
}
if (!resendClient) {
resendClient = new Resend(process.env.RESEND_API_KEY);
}
return resendClient;
}Sopra c'è sendEmail(), che usa sempre config.resend.fromAdmin come mittente. Oggi nel repo un solo punto la chiama davvero: libs/emails/checkoutAccess.ts, invocata dal webhook Stripe quando arriva checkout.session.completed. Questo significa che al momento non hai nessuna superficie pubblica che manda email — e la cosa importante è non aprirne una per sbaglio.
Prima di tutto: verifica il dominio
Senza dominio verificato non parte niente (o parte e finisce in spam). Su resend.com/domains aggiungi il dominio e metti i record DNS (SPF, DKIM, e il record MX per il return path). In config.ts i due mittenti puntano già a un sottodominio:
resend: {
fromNoReply: `ZeroToShip <noreply@resend.zerotoship.com>`,
fromAdmin: `Alberto at ZeroToShip <alberto@resend.zerotoship.com>`,
supportEmail: "ravasini.aziendale@gmail.com",
},Usare un sottodominio dedicato (resend.tuodominio.com) non è estetica: se qualcuno abusa del tuo sistema e quel dominio finisce in blacklist, la tua posta personale su tuodominio.com non viene trascinata giù insieme.
I limiti che Resend applica a te
- Rate limit sull'API: Resend accetta un numero limitato di richieste al secondo per account (di default una manciata). Se lo superi ricevi un
429esendEmail()lancia. - Quota del piano: un tetto giornaliero e uno mensile legati al piano che hai. Il numero esatto lo vedi nella tua dashboard — cambia nel tempo, non fidarti di quello che leggi in giro.
- Destinatari per chiamata: il campo
toaccetta un array, ma con un massimo di indirizzi per singola richiesta. Per liste vere usa Broadcasts, nonsendEmail()in un ciclo.
Un dettaglio che quasi nessuno imposta: quando crei la API key su Resend puoi darle permessi di solo invio e limitarla a un singolo dominio. Fallo. Una chiave con permessi pieni che finisce in un log ti fa perdere anche i domini e i contatti.
Perché il tetto te lo devi mettere tu
Il limite di Resend ti protegge da un'esplosione istantanea, non da un'emorragia lenta. Uno script che manda 3 email al secondo per una notte intera resta sotto il rate limit e ti brucia la quota mensile, ti fa arrivare le segnalazioni di spam, e a quel punto la deliverability te la riprendi in settimane. Un tetto mensile deciso da te si accorge del problema molto prima.
Un contatore mensile lato codice
Nel repo non c'è nessun contatore: se lo vuoi, va aggiunto. Sfrutta il database Supabase che hai già. Prima la tabella:
create table if not exists email_log (
id bigint generated always as identity primary key,
to_email text not null,
subject text,
sent_at timestamptz not null default now()
);
create index if not exists email_log_sent_at_idx on email_log (sent_at desc);
-- Nessuna policy: solo la SUPABASE_SECRET_KEY può leggere e scrivere qui.
alter table email_log enable row level security;Poi un piccolo modulo. Questo file non c'è ancora nel repo: crealo.
import { SupabaseClient } from "@supabase/supabase-js";
// Il tetto lo decidi tu. Tienilo appena sopra il tuo volume normale.
const MONTHLY_CAP = 2000;
const admin = new SupabaseClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SECRET_KEY
);
export async function assertEmailQuota() {
const since = new Date();
since.setUTCDate(1);
since.setUTCHours(0, 0, 0, 0);
const { count, error } = await admin
.from("email_log")
.select("id", { count: "exact", head: true })
.gte("sent_at", since.toISOString());
// Se il conteggio fallisce non blocchiamo le email transazionali.
if (error) return;
if ((count ?? 0) >= MONTHLY_CAP) {
throw new Error("Monthly email cap reached");
}
}
export async function logEmail(to: string | string[], subject: string) {
await admin.from("email_log").insert({
to_email: Array.isArray(to) ? to.join(",") : to,
subject,
});
}E infine due righe dentro sendEmail(), che nel repo oggi non ci sono:
import { assertEmailQuota, logEmail } from "@/libs/emails/quota";
export const sendEmail = async ({ to, subject, text, html, replyTo }) => {
await assertEmailQuota();
const { data, error } = await getResend().emails.send({
from: config.resend.fromAdmin,
to,
subject,
text,
html,
...(replyTo && { replyTo }),
});
if (error) {
console.error("Error sending email:", error.message);
throw error;
}
await logEmail(to, subject);
return data;
};Cosa succede quando un invio fallisce
sendEmail() logga e rilancia l'errore. Chi la chiama deve decidere se è un problema fatale o no. Nel webhook Stripe la decisione è già presa, ed è quella giusta:
// Send the "click here to sign in with GitHub" email.
// Isolated in try/catch so a Resend hiccup can't fail the webhook
// (Stripe would then retry and we'd double-grant access).
if (customer.email) {
try {
await sendCheckoutAccessEmail(customer.email);
} catch (e) {
console.error("checkout access email failed:", (e as Error)?.message);
}
}Tienila come regola: l'email non deve mai far fallire l'operazione di business. Se rilanci fuori dal try, il webhook risponde 500, Stripe lo riprova, e il tuo codice concede l'accesso due volte. Meglio un'email persa e un log da controllare.
Proteggere /api/lead
app/api/lead/route.ts oggi fa un solo controllo:
const body = await req.json();
if (!body.email) {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}Nessun rate limiting, nessun controllo di formato, nessuna auth — è voluto, è il punto di partenza da cui costruire. Il momento in cui ci attacchi un sendEmail() (welcome email, freebie, conferma waitlist) quell'endpoint diventa un mandatore di email gratuito per chiunque. Prima di farlo:
- Valida davvero l'indirizzo: oggi passa qualsiasi valore truthy, anche un numero o un oggetto. Vedi Validazione degli input con Zod.
- Metti un rate limit per IP e per indirizzo. Vedi Rate limiting: API route.
- Non mandare l'email in linea: salva il lead e manda dopo (cron, o batch manuale). Se non mandi nulla in tempo reale, non c'è nulla da amplificare.
- Rispondi sempre uguale. Se rispondi “già iscritto” a un indirizzo esistente e “ok” a uno nuovo, hai appena regalato uno strumento per capire chi è nel tuo database.
Ultima cosa: RESEND_API_KEY non ha e non deve mai avere il prefisso NEXT_PUBLIC_. Non importare mai libs/resend.ts da un componente client — con "use client" in cima al file la chiave finirebbe nel bundle del browser.