Node.js / TypeScript SDK

SDK ufficiale Node.js per Tratto Email API

SDK ufficiale per Node.js e TypeScript: @tratto/email

Ogni esempio di questa pagina compila contro la 1.8.0 pubblicata. Dove una funzionalità richiede una versione precisa, la pagina dichiara la minima che ce l'ha, mai la più recente: quella invecchia da sola.

Installazione

npm install @tratto/email

Inizializzazione

La classe esportata è Tratto, e la chiave API è un argomento posizionale, non un oggetto di opzioni:

import { Tratto } from '@tratto/email';

const tratto = new Tratto(process.env.TRATTO_API_KEY!);

Un secondo argomento facoltativo sovrascrive la base URL:

const tratto = new Tratto(process.env.TRATTO_API_KEY!, {
  baseUrl: 'https://api.tratto.email',
});

timeout fissa quanto può durare ogni richiesta, in millisecondi, e richiede la 1.8.0 o successiva. Il valore predefinito è 60000 (60 secondi). Una richiesta che dura di più lancia TrattoError con code: 'timeout' e statusCode: 0:

const tratto = new Tratto(process.env.TRATTO_API_KEY!, { timeout: 10_000 });

Un invio andato in timeout può essere arrivato comunque all'API. Ritentalo con la stessa chiave di idempotenza, così non parte due volte.

Dalla 1.8.0 l'SDK codifica (percent-encoding) ogni id che inserisce nel path dell'URL, quindi un id che contiene /, ? o # non può cambiare la rotta chiamata.

Invia Email

emails.send risolve a { id }, nient'altro. Per leggere lo status, rileggi l'email.

const { id } = await tratto.emails.send({
  from: 'hello@tuodominio.it',
  to: 'user@example.com',
  subject: 'Ciao!',
  text: 'Benvenuto a Tratto',
  html: '<h1>Benvenuto!</h1>',
});

console.log(id); // email_abc123

from si può omettere dalla 1.5.0 o successiva: Tratto usa allora il mittente transazionale del workspace, o il mittente generale se manca. Vedi Mittenti.

La chiave di idempotenza è il secondo argomento:

await tratto.emails.send(
  { from: 'hello@tuodominio.it', to: 'user@example.com', subject: 'Ciao', text: 'Ciao' },
  'ordine-4711-conferma',
);

Invia con Template

const { id } = await tratto.emails.send({
  from: 'hello@tuodominio.it',
  to: 'user@example.com',
  subject: 'Benvenuta, Alice!',
  templateId: 'tmpl_abc123',
  variables: {
    firstName: 'Alice',
    code: 'SECRET123',
  },
});

subject è obbligatorio su ogni invio, con o senza template. Un template non ha un oggetto proprio — vedi Template.

Invia con Markdown

Invia Markdown e lascia che il server renderizzi HTML responsive per email — markdown è mutuamente esclusivo con html:

const { id } = await tratto.emails.send({
  from: 'hello@tuodominio.it',
  to: 'user@example.com',
  subject: 'Benvenuto!',
  markdown: '# Benvenuto, {{firstName}}!',
  variables: { firstName: 'Alice' },
});

Anche templates.create e templates.update accettano markdown, e il tipo di risposta Template espone format, source e renderWarnings.

Programma un'Email

const { id } = await tratto.emails.send({
  from: 'hello@tuodominio.it',
  to: 'user@example.com',
  subject: 'Promemoria',
  text: 'Ci vediamo domani!',
  scheduledAt: new Date('2026-10-01T09:00:00Z'),
});

Finché l'email è ancora scheduled puoi annullarla o spostarla a un'altra data. Entrambi i metodi richiedono la 1.8.0 o successiva:

await tratto.emails.reschedule('email_abc123', new Date('2026-10-02T09:00:00Z'));
await tratto.emails.cancel('email_abc123'); // la sua quota torna al mese

Quando l'email non è più scheduled, entrambi rispondono 409 CONFLICT. Spostarla in un altro mese UTC può rispondere 429 QUOTA_EXCEEDED se quel mese è pieno, e un 429 RATE_LIMITED si può ritentare senza rischi.

Invia un Batch

emails.sendBatch() invia fino a 100 email in una sola richiesta e richiede la 1.6.0 o successiva. Ogni email accetta i campi di emails.send() tranne scheduledAt; il secondo argomento, facoltativo, è la chiave di idempotenza, che vale per tutto il batch.

import { randomUUID } from 'node:crypto';

const batch = await tratto.emails.sendBatch(
  [
    { from: 'hello@yourdomain.com', to: 'alice@example.com', subject: 'La tua ricevuta', text: 'Grazie, Alice.' },
    { from: 'hello@yourdomain.com', to: 'bob@example.com', subject: 'La tua ricevuta', text: 'Grazie, Bob.' },
  ],
  randomUUID(),
);

console.log(`${batch.accepted} in coda, ${batch.failed} non inviate`);
for (const e of batch.emails) {
  if (e.status === 'failed') console.error(e.to, e.error.code, e.error.message);
}

La chiamata si risolve a ogni 202, anche quando alcune email non sono partite: un'email non partita non ha id. Invia di nuovo solo quelle, in un nuovo batch con una nuova chiave. Dopo un 503 o un 500 non è partito niente: ripeti la stessa chiamata con la stessa chiave. Quota, limiti ed errori sono in Invio in Batch.

Allegati

Richiede la @tratto/email 1.7.0 o successiva e un piano a pagamento: il piano Free risponde 403 PLAN_UPGRADE_REQUIRED. Prima carica il file, poi indicane l'id in emails.send() o emails.sendBatch().

import { readFile } from 'node:fs/promises';

const file = await tratto.attachments.upload(await readFile('fattura.pdf'), {
  filename: 'fattura.pdf',
  contentType: 'application/pdf',
});

await tratto.emails.send({
  from: 'billing@yourdomain.com',
  to: 'alice@example.com',
  subject: 'La tua fattura',
  html: '<p>In allegato.</p>',
  attachments: [{ id: file.id }],
});

const meta = await tratto.attachments.get(file.id); // solo i metadati, mai i byte
await tratto.attachments.delete(file.id);

Il contenuto può essere un Buffer, un Uint8Array, un ArrayBuffer o un Blob. Limiti, scadenza e spazio sono in Allegati.

Leggi lo Status di un'Email

const email = await tratto.emails.get('email_abc123');
console.log(email.status); // 'delivered'
console.log(email.events); // timeline completa

Elenca le Email

const emails = await tratto.emails.list({
  limit: 50,
  after: 'cursor_value', // per la paginazione
});

emails.data.forEach((email) => {
  console.log(`${email.id}: ${email.status}`);
});

Aggiungi un Dominio

Il metodo è add e prende il dominio come stringa:

const domain = await tratto.domains.add('hello.tuodominio.it');

// `records` è un array di record DNS, non un oggetto con una chiave per tipo
for (const record of domain.records) {
  console.log(`${record.type} ${record.host} → ${record.value}`);
}

Verifica un Dominio

const verified = await tratto.domains.verify('dom_abc123');
console.log(verified.status); // 'pending' | 'verified' | 'failed'

Crea un Contatto

Le proprietà personalizzate stanno in customFields. Il campo metadata non esiste:

const { id } = await tratto.contacts.create({
  email: 'user@example.com',
  firstName: 'Alice',
  lastName: 'Smith',
  tags: ['vip', 'newsletter'],
  customFields: { plan: 'pro' },
});

Crea una Campagna

CreateCampaignParams richiede ancora templateId, audienceId, fromName, fromEmail e subjectA. create risolve a { id }:

const { id } = await tratto.campaigns.create({
  name: 'Saldi Q3',
  templateId: 'tmpl_abc123',
  audienceId: 'aud_abc123',
  fromName: 'Acme',
  fromEmail: 'hello@tuodominio.it',
  subjectA: 'I saldi estivi iniziano oggi',
  subjectB: 'Saldi estivi: 30% di sconto, solo oggi', // oggetto A/B, facoltativo
});

const campaign = await tratto.campaigns.get(id);
console.log(campaign.status); // 'draft'

L'API è più permissiva dei tipi della 1.8.0: accetta html inline al posto di un templateId, e interpreta un audienceId omesso come «tutti i contatti del workspace». Nessuna delle due cose è esprimibile con l'SDK: per quelle usa HTTP.

Invia una Campagna

const sent = await tratto.campaigns.send('camp_xyz789');
console.log(sent.status); // 'sending'

Per programmarla, passa una data:

await tratto.campaigns.send('camp_xyz789', {
  scheduledAt: new Date('2026-10-01T09:00:00Z'),
});

Ferma una Campagna

pause trattiene una campagna che sta spedendo o che è in attesa. unschedule annulla un invio programmato e riporta la campagna a draft, e richiede la 1.2.0 o successiva:

await tratto.campaigns.pause('camp_xyz789');
await tratto.campaigns.unschedule('camp_xyz789');

unschedule vale solo mentre lo status è scheduled e non è uscito niente. Su una campagna che sta spedendo, già in pausa, o che aspetta la sua ondata di prova risponde 409 CONFLICT, perché una parte della lista può già avere l'email. Mettila in pausa invece.

Campaign.pausedReason dice perché una campagna si è fermata da sola, e richiede a sua volta la 1.2.0 o successiva. Vale 'quota_exceeded' | 'schedule_missed' | 'bounce_rate' | 'quota_unavailable' | null. 'bounce_rate' è lo stop automatico sui rimbalzi. 'quota_unavailable' vuol dire che non è stato possibile leggere la quota mensile per un errore nostro, non che sia esaurita, quindi la campagna si può riprendere; il tipo lo include dalla 1.8.0 o successiva. Tutti e quattro sono spiegati in Perché una campagna è in pausa.

const campaign = await tratto.campaigns.get('camp_xyz789');
if (campaign.pausedReason === 'bounce_rate') {
  // Pulisci la lista prima di richiamare send.
}

Statistiche di una Campagna

Il metodo è getStats. I tassi sono percentuali: 50 significa 50%, non 0.5 — la stessa scala di Analytics.

const stats = await tratto.campaigns.getStats('camp_xyz789');

console.log(stats.stats.delivered);   // 4950
console.log(stats.rates.openRate);    // 50 → 50%
console.log(stats.rates.deliveryRate) // 99 → 99%

Un alert scritto come rates.deliveryRate < 0.95 non scatterà mai. Il confronto giusto è < 95.

Registra un Webhook

const webhook = await tratto.webhooks.create({
  url: 'https://tuoapi.com/webhooks/tratto',
  events: ['sent', 'delivered', 'opened', 'clicked', 'bounced'],
});

console.log(webhook.secret); // whsec_xyz789...

Verifica i Webhook

verifyWebhook controlla l'header X-Tratto-Signature di una consegna e richiede la 1.8.0 o successiva. Passagli il body grezzo esattamente come arriva, prima di qualunque parsing JSON:

import { verifyWebhook } from '@tratto/email';

export async function POST(request: Request) {
  const rawBody = await request.text(); // leggilo una volta, prima del parsing

  const ok = verifyWebhook(
    rawBody,
    request.headers.get('x-tratto-signature'),
    process.env.TRATTO_WEBHOOK_SECRET!,
  );
  if (!ok) return new Response('Firma non valida', { status: 401 });

  const event = JSON.parse(rawBody);
  console.log(event.type);
  return Response.json({ ok: true });
}

Il body può essere anche un Buffer o un Uint8Array, per esempio quello di express.raw({ type: 'application/json' }).

Restituisce false per un header mancante, malformato, scaduto o che non corrisponde, e lancia un'eccezione solo con un secret vuoto o una tolleranza negativa. La tolleranza è di 5 minuti in entrambe le direzioni; la cambi con { toleranceSeconds: 600 } come quarto argomento. Come è costruita la firma è spiegato in Verifica della firma dei webhook.

Analytics

AnalyticsPeriod è '7d' | '30d' | '90d' | '180d' | '1y' (gli ultimi due richiedono la 1.1.1 o successiva):

const summary = await tratto.analytics.getSummary('30d');
console.log(summary.delivered);

180d e 1y leggono l'aggregato di lungo periodo, che contiene solo dati live: l'API li rifiuta per una chiave in modalità test.

Limiti del Workspace

workspace.get() restituisce in limits i tetti del piano del workspace. Il tipo Workspace.limits (WorkspaceLimits) richiede la 1.8.0 o successiva:

const workspace = await tratto.workspace.get();
const { emailsPerMonth, domains, webhooks } = workspace.limits;
console.log(emailsPerMonth ?? 'nessun limite', domains ?? 'nessun limite', webhooks);

I sette campi sono domains, emailsPerMonth, membersPerAccount, membersPerWorkspace, workspaces, webhooks e testSendsPerDay. domains ed emailsPerMonth valgono null quando il piano non ha limite.

Gestione degli Errori

TrattoError espone code, statusCode, docs, details e suggestion:

import { Tratto, TrattoError } from '@tratto/email';

const tratto = new Tratto(process.env.TRATTO_API_KEY!);

try {
  await tratto.emails.send({
    from: 'hello@nonverificato.it', // non verificato
    to: 'user@example.com',
    subject: 'Ciao',
    text: 'Test',
  });
} catch (error) {
  if (error instanceof TrattoError) {
    // Il messaggio nomina il dominio non verificato.
    console.error(error.code, error.statusCode, error.message);
  }
}

Vedi Codici di Errore per l'elenco completo.

Su un 422 VALIDATION_ERROR, details indica quale campo è sbagliato, per esempio [{ path: '/emails/3/to', message: '...' }] per la quarta email di un batch. Richiede la 1.6.0 o successiva e vale undefined quando l'API non lo invia.

suggestion è il consiglio dell'API su cosa fare, per esempio aspettare e ritentare o correggere un'impostazione. Richiede la 1.8.0 o successiva e vale undefined quando l'errore non ne porta uno. È il campo da leggere quando un codice copre più situazioni, come il CONFLICT di campaigns.send:

try {
  await tratto.campaigns.send('camp_xyz789');
} catch (error) {
  if (error instanceof TrattoError) {
    console.error(error.code, error.suggestion ?? error.message);
  }
}

Un errore con code: 'timeout' e statusCode: 0 è il timeout del client, non una risposta dell'API.

Cosa l'SDK non copre ancora

Questi endpoint esistono nell'API e non hanno un metodo SDK nella 1.8.0. Chiamali via HTTP finché l'SDK non li recupera:

FunzionalitàHTTP
Leggere un singolo contattoGET /v1/contacts/{id}
Modificare / eliminare una campagnaPATCH, DELETE /v1/campaigns/{id}
Click sui link di una campagnaGET /v1/campaigns/{id}/links
Modificare / eliminare / ricalcolare un'audiencePATCH, DELETE, POST …/refresh su /v1/audiences/{id}
Elencare o rimuovere i contatti di un'audienceGET, DELETE su /v1/audiences/{id}/contacts
Anteprima di rendering del markdownPOST /v1/templates/render-preview
Gestione delle chiavi API/v1/api-keys
Logo del marchio di workspacePUT, DELETE /v1/workspace/brand/logo

Mancano dai tipi della 1.8.0 anche alcuni campi di risposta più recenti: Workspace non ha brand, Contact non ha trackingOptOut, e CampaignStats non ha skipped, untracked né variants. L'API li restituisce; i tipi non li descrivono ancora.

Verificato sulla @tratto/email 1.8.0 pubblicata, il 2026-10-27: le otto righe sono ancora scoperte, emails.cancel ed emails.reschedule sono coperti dalla 1.8.0, e ListContactsParams.q è coperto dalla 1.4.0 (contacts.list({ q })). Ogni riga di questa tabella è la promessa che la lacuna c'è ancora: va riletta a ogni rilascio dell'SDK.



Modifica questa pagina su GitHub

Ultimo aggiornamento