How to Send Emails from Angular (2026 Guide)

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.
The Pattern
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.
Pick a Provider
Every code example below lets you switch between three providers.
- Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one SDK. Native Stripe integration and built-in retries.
- Resend is developer-friendly. Clean API, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
- SendGrid is the enterprise option. Feature-rich, high volume. Bigger API surface.
Install
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_hereInitialize the Backend
import 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 };Angular Email Service
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(HttpClient);
sendContactEmail(data: ContactRequest): Observable<ApiResponse> {
return this.http.post<ApiResponse>("/api/contact", data);
}
requestPasswordReset(email: string): Observable<ApiResponse> {
return this.http.post<ApiResponse>("/api/forgot-password", { email });
}
subscribe(email: string, name: string): Observable<ApiResponse> {
return this.http.post<ApiResponse>("/api/subscribe", { email, name });
}
}Send Your First Email
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>
}
</div>
<div>
<textarea formControlName="message" placeholder="Your message" rows="4"></textarea>
@if (form.controls.message.touched && form.controls.message.errors?.['minlength']) {
<span style="color: red; font-size: 12px">Message must be at least 10 characters</span>
}
</div>
<button type="submit" [disabled]="sending() || form.invalid">
{{ sending() ? 'Sending...' : 'Send Message' }}
</button>
@if (status() === 'sent') {
<p style="color: green">Message sent!</p>
}
@if (status() === 'error') {
<p style="color: red">{{ errorMessage() }}</p>
}
</form>
`,
})
export class ContactFormComponent {
private emailService = inject(EmailService);
private fb = inject(FormBuilder);
form = this.fb.group({
name: ["", [Validators.required]],
email: ["", [Validators.required, Validators.email]],
message: ["", [Validators.required, Validators.minLength(10)]],
});
sending = signal(false);
status = signal<"idle" | "sent" | "error">("idle");
errorMessage = signal("");
onSubmit() {
if (this.form.invalid) return;
this.sending.set(true);
this.status.set("idle");
this.emailService
.sendContactEmail(this.form.value as { name: string; email: string; message: string })
.subscribe({
next: () => {
this.status.set("sent");
this.sending.set(false);
this.form.reset();
},
error: (err) => {
this.status.set("error");
this.errorMessage.set(err.error?.error || "Failed to send. Try again.");
this.sending.set(false);
},
});
}
}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;React Email Templates
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",
margin: "40px auto",
maxWidth: "560px",
}}>
{children}
<Hr style={{ borderColor: "#e6ebf1", margin: "32px 0" }} />
<Text style={{ color: "#8898aa", fontSize: "12px" }}>
YourApp Inc. · 123 Main St · San Francisco, CA
</Text>
</Container>
</Body>
</Html>
);
}// 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: "#4a4a4a", lineHeight: "26px" }}>
Your account is ready. Here's what to do next:
</Text>
<Text style={{ fontSize: "16px", color: "#4a4a4a", lineHeight: "26px" }}>
1. Set up your first project{"\n"}
2. Invite your team{"\n"}
3. Connect your integrations
</Text>
<Button
href={loginUrl}
style={{
backgroundColor: "#5046e5",
color: "#ffffff",
padding: "12px 24px",
borderRadius: "6px",
textDecoration: "none",
display: "inline-block",
marginTop: "16px",
}}
>
Go to Dashboard
</Button>
</EmailLayout>
);
}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;Common SaaS Patterns
Password Reset
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>
@if (error()) {
<p style="color: red">{{ error() }}</p>
}
</form>
}
`,
})
export class ForgotPasswordComponent {
private emailService = inject(EmailService);
private fb = inject(FormBuilder);
form = this.fb.group({
email: ["", [Validators.required, Validators.email]],
});
sending = signal(false);
submitted = signal(false);
error = signal("");
onSubmit() {
if (this.form.invalid) return;
this.sending.set(true);
this.error.set("");
this.emailService
.requestPasswordReset(this.form.value.email!)
.subscribe({
next: () => {
this.submitted.set(true);
this.sending.set(false);
},
error: () => {
this.error.set("Something went wrong. Try again.");
this.sending.set(false);
},
});
}
}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;Stripe Webhook
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.
Error Handling
Backend Error Wrapper
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" };
}
}Angular Error Interceptor
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");
}
return throwError(() => error);
}),
);
};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])),
],
};Development Proxy
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"
}
}
}
}Angular SSR (Optional)
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 Sequenzy = (await import("sequenzy")).default;
const client = new Sequenzy();
return client.transactional.send({ to, subject, body });
}
// Client-side: call the API
return this.http.post("/api/send-email", { to, subject, body }).toPromise();
}
}For most apps, a separate Express backend is simpler and more explicit.
Production Checklist
1. Verify Your Sending Domain
| 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.
2. Never Put API Keys in Angular
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.
3. Input Validation on Both Sides
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),
});
// In route handler:
const parsed = contactSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.issues[0].message });
}4. Rate Limiting
// 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);Beyond Transactional
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 emailService = inject(EmailService);
email = "";
sending = signal(false);
subscribed = signal(false);
onSubmit() {
this.sending.set(true);
this.emailService.subscribe(this.email, "").subscribe({
next: () => this.subscribed.set(true),
error: () => this.sending.set(false),
});
}
}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.
FAQ
Can I send emails directly from Angular?
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.
Should I use Angular SSR for email sending?
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.
Can I use Observables or Promises for email API calls?
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.
How do I handle loading states in Angular?
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.
Do I need CORS configuration?
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" }));How do I test email sending in Angular?
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.
Can I use template-driven forms instead of reactive forms?
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.
How do I handle file attachments in emails?
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.
Wrapping Up
- Email service wraps all API calls with proper TypeScript interfaces
- Reactive forms with validators catch bad input before it hits the server
- Signals (Angular 17+) manage loading and success states reactively
- Express backend keeps API keys secure and handles email sending
- React Email builds maintainable templates on the backend
- Proxy configuration bridges Angular dev server to the backend
- Error interceptor centralizes HTTP error handling
Pick your provider, build the backend, and connect your Angular forms.
Frequently Asked Questions
Can I send emails directly from Angular without a backend?
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.
What backend should I pair with Angular for email sending?
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.
How do I handle form validation before sending emails in Angular?
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.
How do I show loading states while emails are being sent?
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.
Should I use Angular's HttpClient interceptors for email error handling?
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.
How do I test email-sending components in Angular?
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.
Can I use RxJS retry operators for failed email sends?
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.
How do I prevent duplicate email sends from double-clicks in Angular?
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.
Should I use Angular signals or observables for email form state?
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.
How do I add CSRF protection to email-sending endpoints called from Angular?
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.