Angular runs in the browser. You can't send emails directly from the client, because that would expose your API key to anyone who opens DevTools. Instead, you call a backend API that holds your key and sends the email server-side.
Most Angular email tutorials show a basic HttpClient.post() and stop. That ignores reactive forms with validation, error handling, loading states, Angular SSR, React Email templates, and the common patterns every SaaS app needs: password resets, payment receipts, Stripe webhooks.
This guide covers both the Angular frontend and the Express backend, with working TypeScript examples. If you're using a different frontend framework, see our guides for React or Next.js. For the backend side alone, check our Express guide.
Angular Component → HttpClient.post("/api/send-email") → Express Backend → Email Provider
With Angular SSR, you can also send emails directly from server-side code without a separate backend.
Every code example below lets you switch between three providers.
Install the SDK on your backend (not in the Angular project):
npm install sequenzy expressnpm install resend expressnpm install @sendgrid/mail expressAdd your API key to the backend's .env:
SEQUENZY_API_KEY=sq_your_api_key_hereRESEND_API_KEY=re_your_api_key_hereSENDGRID_API_KEY=SG.your_api_key_hereimport Sequenzy from "sequenzy";
// Reads SEQUENZY_API_KEY from env automatically
export const sequenzy = new Sequenzy();import { Resend } from "resend";
export const resend = new Resend(process.env.RESEND_API_KEY);import sgMail from "@sendgrid/mail";
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
export { sgMail };Create a service that wraps all email-related API calls:
// src/app/services/email.service.ts
import { Injectable, inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
export interface ContactRequest {
name: string;
email: string;
message: string;
}
export interface ApiResponse {
success: boolean;
error?: string;
}
@Injectable({ providedIn: "root" })
export class EmailService {
private http = inject
A contact form using reactive forms with validation, signals for state management, and the new Angular control flow syntax:
// src/app/components/contact-form.component.ts
import { Component, inject, signal } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { EmailService } from "../services/email.service";
@Component({
selector: "app-contact-form",
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div>
<input formControlName="name" placeholder="Your name" />
@if (form.controls.name.touched && form.controls.name.errors?.['required']) {
<span style="color: red; font-size: 12px">Name is required</span>
}
</div>
<div>
<input formControlName="email" type="email" placeholder="Your email" />
@if (form.controls.email.touched && form.controls.email.errors?.['email']) {
<span style="color: red; font-size: 12px">Invalid email address</span>
}
The backend endpoint:
import { Router } from "express";
import { sequenzy } from "../email";
const router = Router();
router.post("/api/contact", async (req, res) => {
const { name, email, message } = req.body;
if (!name || !email || !message) {
return res.status(400).json({ error: "All fields are required" });
}
try {
await sequenzy.transactional.send({
to: "you@yourcompany.com",
subject: `Contact from ${name}`,
body: `
<h2>New Contact Form Submission</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message}</p>
`,
});
res.json({ success: true });
} catch {
res.status(500).json({ error: "Failed to send message" });
}
});
export default router;import { Router } from "express";
import { resend } from "../email";
const router = Router();
router.post("/api/contact", async (req, res) => {
const { name, email, message } = req.body;
if (!name || !email || !message) {
return res.status(400).json({ error: "All fields are required" });
}
const { error } = await resend.emails.send({
from: "Contact <noreply@yourdomain.com>",
to: "you@yourcompany.com",
subject: `Contact from ${name}`,
html: `
<h2>New Contact Form Submission</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message}</p>
`,
});
if (error) {
return res.status(500).json({ error: "Failed to send message" });
}
res.json({ success: true });
});
export default router;import { Router } from "express";
import { sgMail } from "../email";
const router = Router();
router.post("/api/contact", async (req, res) => {
const { name, email, message } = req.body;
if (!name || !email || !message) {
return res.status(400).json({ error: "All fields are required" });
}
try {
await sgMail.send({
to: "you@yourcompany.com",
from: "noreply@yourdomain.com",
subject: `Contact from ${name}`,
html: `
<h2>New Contact Form Submission</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Message:</strong></p>
<p>${message}</p>
`,
});
res.json({ success: true });
} catch {
res.status(500).json({ error: "Failed to send message" });
}
});
export default router;Inline HTML strings are hard to maintain. React Email lets you build email templates as components that compile to email-safe HTML. Install it on your backend:
npm install @react-email/components react-email react react-dom// server/emails/layout.tsx
import {
Html, Head, Body, Container, Text, Hr,
} from "@react-email/components";
interface EmailLayoutProps {
children: React.ReactNode;
}
export function EmailLayout({ children }: EmailLayoutProps) {
return (
<Html>
<Head />
<Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
<Container style={{
backgroundColor: "#ffffff",
padding: "40px",
borderRadius: "8px",
// server/emails/welcome.tsx
import { Text, Button, Heading } from "@react-email/components";
import { EmailLayout } from "./layout";
interface WelcomeEmailProps {
name: string;
loginUrl: string;
}
export function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
return (
<EmailLayout>
<Heading as="h1" style={{ fontSize: "24px", color: "#1a1a1a" }}>
Welcome, {name}!
</Heading>
<Text style={{ fontSize: "16px", color:
Render and send from the backend:
import { Router } from "express";
import { render } from "@react-email/components";
import { sequenzy } from "../email";
import { WelcomeEmail } from "../emails/welcome";
const router = Router();
router.post("/api/signup", async (req, res) => {
const { email, name, password } = req.body;
// ... create user account ...
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);
await sequenzy.transactional.send({
to: email,
subject: `Welcome to YourApp, ${name}!`,
body: html,
});
res.json({ success: true });
});
export default router;import { Router } from "express";
import { render } from "@react-email/components";
import { resend } from "../email";
import { WelcomeEmail } from "../emails/welcome";
const router = Router();
router.post("/api/signup", async (req, res) => {
const { email, name, password } = req.body;
// ... create user account ...
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);
const { error } = await resend.emails.send({
from: "YourApp <noreply@yourdomain.com>",
to: email,
subject: `Welcome to YourApp, ${name}!`,
html,
});
if (error) {
return res.status(500).json({ error: "Failed to send welcome email" });
}
res.json({ success: true });
});
export default router;import { Router } from "express";
import { render } from "@react-email/components";
import { sgMail } from "../email";
import { WelcomeEmail } from "../emails/welcome";
const router = Router();
router.post("/api/signup", async (req, res) => {
const { email, name, password } = req.body;
// ... create user account ...
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);
try {
await sgMail.send({
to: email,
from: "noreply@yourdomain.com",
subject: `Welcome to YourApp, ${name}!`,
html,
});
res.json({ success: true });
} catch {
res.status(500).json({ error: "Failed to send welcome email" });
}
});
export default router;Angular component with the forgot password form:
// src/app/components/forgot-password.component.ts
import { Component, inject, signal } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { EmailService } from "../services/email.service";
@Component({
selector: "app-forgot-password",
standalone: true,
imports: [ReactiveFormsModule],
template: `
@if (submitted()) {
<div>
<h2>Check your email</h2>
<p>If an account exists with that email, we sent a password reset link.</p>
</div>
} @else {
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<h2>Forgot your password?</h2>
<p>Enter your email and we'll send you a reset link.</p>
<input formControlName="email" type="email" placeholder="you@example.com" />
<button type="submit" [disabled]="sending() || form.invalid">
{{ sending() ? 'Sending...' : 'Send Reset Link' }}
</button>
The backend:
import { Router } from "express";
import { render } from "@react-email/components";
import { sequenzy } from "../email";
import crypto from "node:crypto";
import { Html, Body, Container, Text, Button } from "@react-email/components";
function ResetEmail({ url }: { url: string }) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
<Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. This link expires in 1 hour.</Text>
<Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
Reset Password
</Button>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "32px" }}>
If you didn't request this, ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
const router = Router();
router.post("/api/forgot-password", async (req, res) => {
const { email } = req.body;
if (!email) {
return res.status(400).json({ error: "Email is required" });
}
// Always return success to prevent email enumeration
const user = await db.user.findByEmail(email);
if (!user) {
return res.json({ success: true });
}
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 60 * 60 * 1000);
await db.resetToken.create({ userId: user.id, token, expires });
const resetUrl = `${process.env.APP_URL}/reset-password?token=${token}`;
const html = await render(<ResetEmail url={resetUrl} />);
await sequenzy.transactional.send({
to: email,
subject: "Reset your password",
body: html,
});
res.json({ success: true });
});
export default router;import { Router } from "express";
import { render } from "@react-email/components";
import { resend } from "../email";
import crypto from "node:crypto";
import { Html, Body, Container, Text, Button } from "@react-email/components";
function ResetEmail({ url }: { url: string }) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
<Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. This link expires in 1 hour.</Text>
<Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
Reset Password
</Button>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "32px" }}>
If you didn't request this, ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
const router = Router();
router.post("/api/forgot-password", async (req, res) => {
const { email } = req.body;
if (!email) {
return res.status(400).json({ error: "Email is required" });
}
const user = await db.user.findByEmail(email);
if (!user) {
return res.json({ success: true });
}
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 60 * 60 * 1000);
await db.resetToken.create({ userId: user.id, token, expires });
const resetUrl = `${process.env.APP_URL}/reset-password?token=${token}`;
const html = await render(<ResetEmail url={resetUrl} />);
await resend.emails.send({
from: "YourApp <noreply@yourdomain.com>",
to: email,
subject: "Reset your password",
html,
});
res.json({ success: true });
});
export default router;import { Router } from "express";
import { render } from "@react-email/components";
import { sgMail } from "../email";
import crypto from "node:crypto";
import { Html, Body, Container, Text, Button } from "@react-email/components";
function ResetEmail({ url }: { url: string }) {
return (
<Html>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
<Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
<Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
<Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. This link expires in 1 hour.</Text>
<Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
Reset Password
</Button>
<Text style={{ color: "#8898aa", fontSize: "12px", marginTop: "32px" }}>
If you didn't request this, ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}
const router = Router();
router.post("/api/forgot-password", async (req, res) => {
const { email } = req.body;
if (!email) {
return res.status(400).json({ error: "Email is required" });
}
const user = await db.user.findByEmail(email);
if (!user) {
return res.json({ success: true });
}
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + 60 * 60 * 1000);
await db.resetToken.create({ userId: user.id, token, expires });
const resetUrl = `${process.env.APP_URL}/reset-password?token=${token}`;
const html = await render(<ResetEmail url={resetUrl} />);
try {
await sgMail.send({
to: email,
from: "noreply@yourdomain.com",
subject: "Reset your password",
html,
});
} catch {
console.error("Failed to send password reset email");
}
res.json({ success: true });
});
export default router;The webhook runs on the backend. Angular doesn't need to know about it.
import { Router } from "express";
import express from "express";
import Stripe from "stripe";
import { sequenzy } from "../email";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const router = Router();
// Stripe needs raw body for signature verification
router.post(
"/api/stripe-webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["stripe-signature"]!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return res.status(400).json({ error: "Invalid signature" });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const email = session.customer_details?.email;
if (email) {
await sequenzy.transactional.send({
to: email,
subject: "Payment received - thank you!",
body: `
<h2>Payment Receipt</h2>
<p>Thanks for your purchase of $${(session.amount_total! / 100).toFixed(2)}.</p>
<p><a href="${process.env.APP_URL}/billing">View your billing</a></p>
`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.customer_email) {
await sequenzy.transactional.send({
to: invoice.customer_email,
subject: "Payment failed - action required",
body: `
<h2>Your payment didn't go through</h2>
<p>We couldn't process your payment of $${(invoice.amount_due / 100).toFixed(2)}.</p>
<p><a href="${process.env.APP_URL}/billing">Update Payment Method</a></p>
`,
});
}
break;
}
}
res.json({ received: true });
},
);
export default router;import { Router } from "express";
import express from "express";
import Stripe from "stripe";
import { resend } from "../email";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const router = Router();
router.post(
"/api/stripe-webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["stripe-signature"]!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return res.status(400).json({ error: "Invalid signature" });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const email = session.customer_details?.email;
if (email) {
await resend.emails.send({
from: "YourApp <billing@yourdomain.com>",
to: email,
subject: "Payment received - thank you!",
html: `
<h2>Payment Receipt</h2>
<p>Thanks for your purchase of $${(session.amount_total! / 100).toFixed(2)}.</p>
<p><a href="${process.env.APP_URL}/billing">View your billing</a></p>
`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.customer_email) {
await resend.emails.send({
from: "YourApp <billing@yourdomain.com>",
to: invoice.customer_email,
subject: "Payment failed - action required",
html: `
<h2>Your payment didn't go through</h2>
<p>We couldn't process your payment of $${(invoice.amount_due / 100).toFixed(2)}.</p>
<p><a href="${process.env.APP_URL}/billing">Update Payment Method</a></p>
`,
});
}
break;
}
}
res.json({ received: true });
},
);
export default router;import { Router } from "express";
import express from "express";
import Stripe from "stripe";
import { sgMail } from "../email";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const router = Router();
router.post(
"/api/stripe-webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["stripe-signature"]!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return res.status(400).json({ error: "Invalid signature" });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const email = session.customer_details?.email;
if (email) {
await sgMail.send({
to: email,
from: "billing@yourdomain.com",
subject: "Payment received - thank you!",
html: `
<h2>Payment Receipt</h2>
<p>Thanks for your purchase of $${(session.amount_total! / 100).toFixed(2)}.</p>
<p><a href="${process.env.APP_URL}/billing">View your billing</a></p>
`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.customer_email) {
await sgMail.send({
to: invoice.customer_email,
from: "billing@yourdomain.com",
subject: "Payment failed - action required",
html: `
<h2>Your payment didn't go through</h2>
<p>We couldn't process your payment of $${(invoice.amount_due / 100).toFixed(2)}.</p>
<p><a href="${process.env.APP_URL}/billing">Update Payment Method</a></p>
`,
});
}
break;
}
}
res.json({ received: true });
},
);
export default router;The key detail: the webhook route uses express.raw({ type: "application/json" }) instead of express.json(). Stripe needs the raw body bytes for signature verification. Register this route before the global app.use(express.json()) middleware, or use route-specific middleware as shown.
import Sequenzy from "sequenzy";
import { sequenzy } from "../email";
interface SendResult {
success: boolean;
error?: string;
}
export async function sendEmailSafe(
to: string,
subject: string,
body: string,
): Promise<SendResult> {
try {
await sequenzy.transactional.send({ to, subject, body });
return { success: true };
} catch (error) {
if (error instanceof Sequenzy.RateLimitError) {
console.warn(`Rate limited. Retry after: ${error.retryAfter}s`);
return { success: false, error: "Too many emails. Try again later." };
}
if (error instanceof Sequenzy.AuthenticationError) {
console.error("Invalid SEQUENZY_API_KEY");
return { success: false, error: "Email service configuration error" };
}
if (error instanceof Sequenzy.ValidationError) {
return { success: false, error: "Invalid email parameters" };
}
console.error("Email error:", error);
return { success: false, error: "Failed to send email" };
}
}import { resend } from "../email";
interface SendResult {
success: boolean;
error?: string;
}
export async function sendEmailSafe(
to: string,
subject: string,
html: string,
): Promise<SendResult> {
const { error } = await resend.emails.send({
from: "YourApp <noreply@yourdomain.com>",
to,
subject,
html,
});
if (error) {
console.error(`Resend error [${error.name}]: ${error.message}`);
switch (error.name) {
case "rate_limit_exceeded":
return { success: false, error: "Too many emails. Try again later." };
case "validation_error":
return { success: false, error: "Invalid email parameters" };
case "invalid_api_key":
return { success: false, error: "Email service configuration error" };
default:
return { success: false, error: "Failed to send email" };
}
}
return { success: true };
}import { sgMail } from "../email";
interface SendResult {
success: boolean;
error?: string;
}
export async function sendEmailSafe(
to: string,
subject: string,
html: string,
): Promise<SendResult> {
try {
await sgMail.send({ to, from: "noreply@yourdomain.com", subject, html });
return { success: true };
} catch (error: unknown) {
const sgError = error as { code?: number };
if (sgError.code === 429) {
return { success: false, error: "Too many emails. Try again later." };
}
if (sgError.code === 401) {
return { success: false, error: "Email service configuration error" };
}
console.error("SendGrid error:", error);
return { success: false, error: "Failed to send email" };
}
}Centralize HTTP error handling with an interceptor:
// src/app/interceptors/error.interceptor.ts
import { HttpInterceptorFn } from "@angular/common/http";
import { catchError, throwError } from "rxjs";
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((error) => {
if (error.status === 429) {
// Rate limited - show a user-friendly message
console.warn("Rate limited. Slow down.");
}
if (error.status === 0) {
// Network error - server is down
console.error("Cannot reach server");
}
Register it in your app config:
// src/app/app.config.ts
import { provideHttpClient, withInterceptors } from "@angular/common/http";
import { errorInterceptor } from "./interceptors/error.interceptor";
export const appConfig = {
providers: [
provideHttpClient(withInterceptors([errorInterceptor])),
],
};Forward API requests from Angular dev server to your backend:
// proxy.conf.json
{
"/api": {
"target": "http://localhost:3000",
"secure": false
}
}ng serve --proxy-config proxy.conf.jsonOr add it to angular.json:
{
"architect": {
"serve": {
"options": {
"proxyConfig": "proxy.conf.json"
}
}
}
}With Angular SSR, you can send emails directly from server-side code without a separate Express backend. Use the isPlatformServer check:
// src/app/services/email-ssr.service.ts
import { Injectable, PLATFORM_ID, inject } from "@angular/core";
import { isPlatformServer } from "@angular/common";
import { HttpClient } from "@angular/common/http";
@Injectable({ providedIn: "root" })
export class EmailSsrService {
private platformId = inject(PLATFORM_ID);
private http = inject(HttpClient);
async sendEmail(to: string, subject: string, body: string) {
if (isPlatformServer(this.platformId)) {
// Server-side: import SDK directly (API key stays on server)
const
For most apps, a separate Express backend is simpler and more explicit.
| Record | Type | Purpose |
|---|---|---|
| SPF | TXT | Authorizes servers to send |
| DKIM | TXT | Cryptographic signature |
| DMARC | TXT | Policy for failed checks |
Our email authentication guide walks through the full DNS configuration.
API keys go in the backend's .env. Never put them in environment.ts or environment.prod.ts. Angular builds are public JavaScript, anyone can read them.
Validate in Angular (for UX) and on the backend (for security):
// Angular: reactive form validation
form = this.fb.group({
email: ["", [Validators.required, Validators.email]],
message: ["", [Validators.required, Validators.minLength(10), Validators.maxLength(5000)]],
});
// Backend: Zod validation
import { z } from "zod";
const contactSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
message: z.string().min(10).max(5000),
});
// server/middleware/rate-limit.ts
import rateLimit from "express-rate-limit";
export const emailRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 5,
message: { error: "Too many requests. Try again later." },
});
// Apply to email routes
app.use("/api/contact", emailRateLimit);
app.use("/api/forgot-password", emailRateLimit);import { Router } from "express";
import { sequenzy } from "../email";
const router = Router();
router.post("/api/subscribe", async (req, res) => {
const { email, name } = req.body;
await sequenzy.subscribers.add({
email,
attributes: { name },
tags: ["signed-up"],
});
res.json({ success: true });
});
export default router;
// Sequenzy triggers welcome sequences automatically
// when the "signed-up" tag is applied. One SDK for
// transactional, marketing, and automations.import { Router } from "express";
import { resend } from "../email";
const router = Router();
router.post("/api/subscribe", async (req, res) => {
const { email, name } = req.body;
const { error } = await resend.contacts.create({
audienceId: process.env.RESEND_AUDIENCE_ID!,
email,
firstName: name,
});
if (error) {
return res.status(500).json({ error: error.message });
}
res.json({ success: true });
});
export default router;
// Resend has one-off broadcasts but no automated
// sequences. You'd need another tool for that.import { Router } from "express";
const router = Router();
router.post("/api/subscribe", async (req, res) => {
const { email, name } = req.body;
const response = await fetch("https://api.sendgrid.com/v3/marketing/contacts", {
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.SENDGRID_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
list_ids: [process.env.SENDGRID_LIST_ID],
contacts: [{ email, first_name: name }],
}),
});
if (!response.ok) {
return res.status(500).json({ error: "Failed to subscribe" });
}
res.json({ success: true });
});
export default router;Angular subscribe component:
// src/app/components/subscribe.component.ts
import { Component, inject, signal } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { EmailService } from "../services/email.service";
@Component({
selector: "app-subscribe",
standalone: true,
imports: [FormsModule],
template: `
@if (subscribed()) {
<p style="color: green">Thanks for subscribing!</p>
} @else {
<form (ngSubmit)="onSubmit()">
<input [(ngModel)]="email" name="email" type="email" placeholder="Your email" required />
<button type="submit" [disabled]="sending()">
{{ sending() ? 'Subscribing...' : 'Subscribe' }}
</button>
</form>
}
`,
})
export class SubscribeComponent {
private
Sequenzy handles transactional sends, marketing campaigns, automated welcome sequences, and subscriber management from one SDK. Native Stripe integration tags subscribers automatically when they purchase, cancel, or churn.
No. Angular runs in the browser. If you put an API key in your Angular code, anyone can extract it from the JavaScript bundle. You must call a backend API that holds your key and sends the email server-side.
For most apps, a separate Express/Fastify backend is simpler. Angular SSR is useful if you want a single deployment and don't want to manage a separate server. But SSR adds complexity (hydration, platform checks), so a dedicated backend is usually the better choice.
Both work. Angular's HttpClient returns Observables by default. For simple fire-and-forget calls, you can convert to a Promise with firstValueFrom() or toPromise(). For form submissions, Observables give you better control over cancellation and retries.
Use signals (Angular 17+) for reactive state. The sending signal tracks whether a request is in progress, and the template reacts automatically. For older Angular versions, use regular class properties with change detection.
In development, use Angular's proxy configuration (proxy.conf.json) to forward /api requests to your backend. In production, either serve Angular from the same origin as the backend, or configure CORS on the backend:
import cors from "cors";
app.use(cors({ origin: "https://yourapp.com" }));Mock the EmailService in your component tests:
const mockEmailService = {
sendContactEmail: () => of({ success: true }),
};
TestBed.configureTestingModule({
providers: [{ provide: EmailService, useValue: mockEmailService }],
});For backend testing, use your provider's sandbox/test API keys.
Yes. Template-driven forms work fine for simple cases. Reactive forms are recommended for complex validation and programmatic control. For a contact form, either approach works.
Use FormData in Angular to upload files to your backend, then attach them to the email:
const formData = new FormData();
formData.append("file", file);
formData.append("email", email);
this.http.post("/api/send-with-attachment", formData).subscribe();On the backend, use multer to parse the upload, then pass the file buffer to your email provider.
Pick your provider, build the backend, and connect your Angular forms.
No. Angular runs in the browser, so exposing email API keys in client-side code would be a security risk. You must send emails through a backend API (Node.js, Express, NestJS, etc.) and call that API from your Angular app via HttpClient.
NestJS is the most natural fit since it uses TypeScript and decorators similar to Angular. Express is a simpler alternative. Both work well as a backend API that Angular calls to trigger email sends.
Use Angular's ReactiveFormsModule with validators like Validators.required and Validators.email. Only call your backend API when the form is valid. This prevents unnecessary API calls and gives users immediate feedback on input errors.
Track a sending boolean in your component. Set it to true before the HTTP call and false in both success and error callbacks. Disable the submit button and show a spinner while sending is true. Use Angular's finalize RxJS operator for clean state management.
Yes. Create an HTTP interceptor that catches 429 (rate limit) and 500 (server error) responses globally. Show a toast notification for errors and optionally retry failed requests. This centralizes error handling instead of duplicating it in every component.
Use Angular's HttpClientTestingModule to mock HTTP calls. Verify that your component sends the correct request payload and handles success and error responses properly. You're testing the Angular side only - backend email logic should have its own tests.
Yes. Use retry(2) or retryWhen with a delay to automatically retry failed sends. This works well for transient network errors. Add a maximum retry count and show an error message to the user if all retries fail.
Disable the submit button immediately on click and use RxJS exhaustMap instead of switchMap for the HTTP call. exhaustMap ignores new requests while one is in flight, preventing duplicate submissions even if the button isn't disabled fast enough.
Either works. Signals (Angular 16+) are simpler for component-local state like loading and error flags. Observables via HttpClient are natural for the HTTP calls themselves. You can mix both - use signals for UI state and observables for data fetching.
If your backend and Angular app share a domain, use cookie-based CSRF tokens. Configure Angular's HttpClientXsrfModule to automatically include the token header. For separate domains, use authorization headers with JWT tokens instead of CSRF.