Angular: SSR e AnalogJS

Invia email da un'applicazione Angular usando @tratto/email sul server.

Non esiste un SDK Angular. Le applicazioni Angular inviano email dal server, usando @tratto/email nel processo Node che renderizza o serve l'applicazione.

Una chiave API dà accesso completo al workspace: inviare dai tuoi domini verificati, leggere la lista contatti, creare nuove chiavi API, rimuovere membri del team, eliminare il workspace. Una chiave spedita al browser è pubblica: si legge nel bundle JavaScript, nei DevTools e nel tab di rete. Nessuna offuscazione cambia questo, ed è il motivo per cui nessun provider email pubblica un SDK per browser.

Il tranello specifico di Angular

React ha un confine di compilazione tra codice server e codice client. Angular no. Tutto ciò che scrivi in un componente, in un servizio o in un resolver finisce compilato nel bundle del browser, anche in un'applicazione SSR. Il server side rendering cambia dove il codice viene eseguito la prima volta: non impedisce al codice di raggiungere il client.

Quindi "usiamo SSR" non è di per sé una protezione. Quello che conta è dove sta la chiave, e se il codice che la legge può mai essere eseguito in un browser.

// ❌ MAI: app.config.ts fa parte del bundle browser
export const appConfig: ApplicationConfig = {
  providers: [
    { provide: TRATTO_KEY, useValue: 'tratto_live_...' },  // spedita a ogni visitatore
  ],
};

L'applicazione continua a funzionare, ed è questo che rende l'errore facile da non notare.

Pattern 1: Chiama Tratto dalle tue route server (consigliato)

Il design più sicuro tiene Tratto completamente fuori dall'applicazione Angular. Il tuo server espone un endpoint, il browser chiama quell'endpoint e il server parla con Tratto.

// server.ts
import {
  AngularNodeAppEngine,
  createNodeRequestHandler,
  isMainModule,
  writeResponseToNodeResponse,
} from '@angular/ssr/node';
import express from 'express';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Tratto } from '@tratto/email';

const browserDistFolder = resolve(
  dirname(fileURLToPath(import.meta.url)),
  '../browser',
);

const app = express();
const angularApp = new AngularNodeAppEngine();
const tratto = new Tratto(process.env['TRATTO_API_KEY']!);

app.use(express.json());

// Endpoint tuo: il browser non vede mai la chiave Tratto.
app.post('/api/contact', async (req, res, next) => {
  try {
    // Valida prima di inviare. Mai interpolare input utente in `from`.
    const { email, message } = req.body ?? {};
    if (typeof email !== 'string' || typeof message !== 'string') {
      return res.status(400).json({ error: 'Invalid payload' });
    }

    const result = await tratto.emails.send({
      from: 'Acme <[email protected]>',
      to: '[email protected]',
      replyTo: email,
      subject: 'Nuovo messaggio dal form di contatto',
      text: message,
    });

    // Restituisci solo ciò che serve al client.
    res.json({ id: result.id });
  } catch (error) {
    next(error);
  }
});

app.use(
  express.static(browserDistFolder, {
    maxAge: '1y',
    index: false,
    redirect: false,
  }),
);

app.use((req, res, next) => {
  angularApp
    .handle(req)
    .then((response) =>
      response ? writeResponseToNodeResponse(response, res) : next(),
    )
    .catch(next);
});

if (isMainModule(import.meta.url)) {
  const port = process.env['PORT'] || 4000;
  app.listen(port);
}

export const reqHandler = createNodeRequestHandler(app);

server.ts viene impacchettato nell'output server e non raggiunge mai il browser.

Pattern 2: Recuperare dati durante il render server

Se ti servono dati Tratto mentre la pagina viene renderizzata, dichiara un tuo injection token e forniscilo solo nella configurazione server.

Il file del token si può importare ovunque, perché dichiara un token e usa import type per il client: dall'SDK non viene emesso nulla.

// app/tratto.token.ts
import { InjectionToken } from '@angular/core';
import type { Tratto } from '@tratto/email';

export const TRATTO = new InjectionToken<Tratto>('TRATTO');

Il provider esiste solo nel bundle server:

// app.config.server.ts: solo bundle server
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/ssr';
import { Tratto } from '@tratto/email';

import { appConfig } from './app.config';
import { TRATTO } from './tratto.token';

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(),
    {
      provide: TRATTO,
      useFactory: () => new Tratto(process.env['TRATTO_API_KEY']!),
    },
  ],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

Nel browser il provider semplicemente non c'è, quindi optional: true restituisce null e la chiamata viene saltata:

import { Component, inject, signal } from '@angular/core';
import { TRATTO } from './tratto.token';

@Component({
  selector: 'app-newsletter-stats',
  template: `<p>{{ subscribers() }} iscritti</p>`,
})
export class NewsletterStatsComponent {
  readonly subscribers = signal(0);

  constructor() {
    // null nel browser: il provider è solo lato server.
    const tratto = inject(TRATTO, { optional: true });
    if (!tratto) return;

    tratto.analytics
      .getSummary({ period: '30d' })
      .then((summary) => this.subscribers.set(summary.subscribers));
  }
}

Trasferisci il risultato con lo state transfer di Angular, così il valore sopravvive all'hydration senza una seconda richiesta.

Preferisci il Pattern 1 ogni volta che il dato non deve stare nell'HTML iniziale. Tiene l'SDK completamente fuori dal grafo delle dipendenze dell'applicazione: una cosa in meno che può rompersi durante un refactor.

Tipizzare i dati nel browser

@tratto/email esporta tutti i tipi di richiesta e risposta, e import type viene cancellato in fase di compilazione: nel bundle non arriva nulla.

import type { EmailEvent } from '@tratto/email';

export interface DeliveryRow {
  event: EmailEvent;
}

Scrivi import type, non import. Con verbatimModuleSyntax TypeScript emette l'import così com'è, trascinando l'intero SDK nel bundle browser. Attiva @typescript-eslint/consistent-type-imports per farlo verificare dal linter.

È preferibile restituire dal server un oggetto ristretto e tipizzare quello: il browser non ha motivo di ricevere un EmailDetail completo per mostrare una stringa di stato.

AnalogJS e Nitro

AnalogJS è il meta-framework Angular costruito su Nitro. Le sue route API server sono l'equivalente del Pattern 1:

// src/server/routes/api/contact.post.ts
import { defineEventHandler, readBody, createError } from 'h3';
import { Tratto } from '@tratto/email';

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

export default defineEventHandler(async (event) => {
  const body = await readBody(event);

  if (typeof body?.email !== 'string') {
    throw createError({ statusCode: 400, statusMessage: 'Invalid payload' });
  }

  const result = await tratto.emails.send({
    from: 'Acme <[email protected]>',
    to: '[email protected]',
    replyTo: body.email,
    subject: 'Nuovo messaggio dal form di contatto',
    text: String(body.message ?? ''),
  });

  return { id: result.id };
});

I file sotto src/server/ vengono impacchettati da Nitro nell'output server e non raggiungono mai il browser.

Fornire la chiave

Leggi la chiave dall'ambiente. Non committarla mai e non metterla in environment.ts: i file environment di Angular fanno parte del bundle browser.

# .env (in gitignore)
TRATTO_API_KEY=tratto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

In sviluppo usa chiavi tratto_test_..., così un errore non può inviare email reali.

Gestione degli errori

@tratto/email solleva TrattoError con il codice di errore dell'API. Logga il dettaglio lato server e restituisci qualcosa di generico:

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

try {
  await tratto.emails.send({ /* … */ });
} catch (error) {
  if (error instanceof TrattoError) {
    console.error(error.code, error.statusCode);
  }
  res.status(500).json({ error: 'Invio non riuscito' });
}

I payload di errore di Tratto possono contenere indirizzi dei destinatari e altri dettagli che non devono uscire dal tuo server. Vedi Codici di Errore.

Checklist

  • La chiave è letta da process.env, mai da environment.ts
  • Nessun provider che porta la chiave esiste in app.config.ts
  • I componenti usano inject(TRATTO, { optional: true }) e gestiscono null
  • I tuoi endpoint validano l'input e hanno un rate limit
  • from è un indirizzo fisso e verificato, mai costruito da input utente
  • Gli errori sono loggati sul server e restituiti in forma generica
  • grep -r "tratto_live" dist/browser non trova nulla dopo una build di produzione

Metti quest'ultimo controllo in CI.


Correlati: SDK Node.js · Autenticazione · React


Modifica questa pagina su GitHub

Ultimo aggiornamento