Angular: SSR and AnalogJS
Send email from an Angular application using @tratto/email on the server.
There is no Angular SDK. Angular applications send email from the server,
using @tratto/email in the Node process that renders or
serves the app.
An API key grants full access to your workspace: send as your verified domains, read your contact list, mint new API keys, remove team members, delete the workspace. A key shipped to the browser is public: readable in the JavaScript bundle, in DevTools and in the network tab. No obfuscation changes that, which is why no email provider ships a browser SDK.
The Angular-specific trap
React draws a compiler boundary between server and client code. Angular does not. Anything you write inside a component, a service or a route resolver is compiled into the browser bundle, even in an SSR application. Server-side rendering changes where the code runs first: it does not keep the code off the client.
So "we use SSR" is not, by itself, protection. What matters is where the key lives, and whether the code that reads it can ever execute in a browser.
// ❌ NEVER: app.config.ts is part of the browser bundle
export const appConfig: ApplicationConfig = {
providers: [
{ provide: TRATTO_KEY, useValue: 'tratto_live_...' }, // shipped to every visitor
],
};The application still works, which is what makes this mistake easy to miss.
Pattern 1: Call Tratto from your server routes (recommended)
The safest design keeps Tratto out of the Angular application entirely. Your server exposes an endpoint, the browser calls that endpoint, and the server talks to 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());
// Your own endpoint: the browser never sees the Tratto key.
app.post('/api/contact', async (req, res, next) => {
try {
// Validate before sending. Never interpolate user input into `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: 'New contact form submission',
text: message,
});
// Return only what the client needs.
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 is bundled into the server output. It never reaches the browser.
Pattern 2: Fetch during server rendering
If you need Tratto data while the page renders, declare your own injection token and provide it only in the server configuration.
The token file is safe to import anywhere, because it declares a token and uses
import type for the client: nothing from the SDK is emitted:
// app/tratto.token.ts
import { InjectionToken } from '@angular/core';
import type { Tratto } from '@tratto/email';
export const TRATTO = new InjectionToken<Tratto>('TRATTO');The provider exists only in the server bundle:
// app.config.server.ts: server bundle only
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);In the browser the provider simply is not there, so optional: true yields
null and the call is skipped:
import { Component, inject, signal } from '@angular/core';
import { TRATTO } from './tratto.token';
@Component({
selector: 'app-newsletter-stats',
template: `<p>{{ subscribers() }} subscribers</p>`,
})
export class NewsletterStatsComponent {
readonly subscribers = signal(0);
constructor() {
// null in the browser: the provider is server-only.
const tratto = inject(TRATTO, { optional: true });
if (!tratto) return;
tratto.analytics
.getSummary({ period: '30d' })
.then((summary) => this.subscribers.set(summary.subscribers));
}
}Transfer the result with Angular's state transfer so the value survives hydration without a second request.
Prefer Pattern 1 whenever the data does not have to be in the initial HTML. It keeps the SDK out of the application's dependency graph entirely, which is one less thing that can go wrong during a refactor.
Typing data in the browser
@tratto/email exports every request and response type, and import type is
erased at compile time, so nothing reaches the bundle:
import type { EmailEvent } from '@tratto/email';
export interface DeliveryRow {
event: EmailEvent;
}Write import type, not import. Under verbatimModuleSyntax TypeScript emits
a plain import verbatim, which pulls the whole SDK into the browser bundle.
Enable @typescript-eslint/consistent-type-imports so the linter catches it.
Prefer returning a narrowed object from your server and typing that: the browser
has no reason to receive a full EmailDetail to render a status string.
AnalogJS and Nitro
AnalogJS is the Angular meta-framework built on Nitro. Its server API routes are the equivalent of 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: 'New contact form submission',
text: String(body.message ?? ''),
});
return { id: result.id };
});Files under src/server/ are bundled by Nitro into the server output and never
reach the browser.
Supplying the key
Read the key from the environment. Never commit it, and never place it in
environment.ts: Angular environment files are part of the browser bundle.
# .env (gitignored)
TRATTO_API_KEY=tratto_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxUse tratto_test_... keys in development so a mistake cannot send real mail.
Error handling
@tratto/email throws TrattoError with the API's error code. Log the detail
server-side and return something generic:
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: 'Could not send the message' });
}Tratto error payloads can contain recipient addresses and other detail that should not leave your server. See Error Codes.
Checklist
- The key is read from
process.env, never fromenvironment.ts - No provider carrying the key exists in
app.config.ts - Components use
inject(TRATTO, { optional: true })and handlenull - Your own endpoints validate input and are rate-limited
-
fromis a fixed, verified address, never built from user input - Errors are logged on the server and returned as generic messages
-
grep -r "tratto_live" dist/browserreturns nothing after a production build
Run that last check in CI.
Related: Node.js SDK · Authentication · React
Edit this page on GitHub
Last updated on