Frameworks and agents · Updated 2026-09-28
Send email from NestJS
A NestJS controller and service sending one order confirmation per order, refusing a malformed address first and answering a refusal with its code and fix.
To send email from a NestJS app, put the AgentiSend client in an injectable service and call it from a controller. The files below answer POST /orders/confirmation: the controller refuses a malformed address before the API is called, the service sends once per order however many times the request is retried, and a refusal comes back with the same status and its code and fix. They are executed against a real API on every build of this site's repository, with the Nest app booted and called over HTTP: one send, the same request again replaying it, and one malformed address refused with nothing sent.
Install
pnpm add @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata rxjs agentisendNest's decorators need experimentalDecorators in tsconfig.json, which every Nest project already sets.
Environment
| Variable | Required | What it is |
|---|---|---|
AGENTISEND_API_KEY | yes | A key with sending_access. |
MAIL_FROM | yes | The From address, on a domain you have verified. |
AGENTISEND_BASE_URL | no | Defaults to https://api.agentisend.com. |
The service
/**
* NestJS — the provider that holds the AgentiSend client and sends the
* order confirmation.
*/
import { Injectable } from '@nestjs/common';
import { AgentiSend } from 'agentisend';
@Injectable()
export class EmailService {
/** One client for the process; it reads AGENTISEND_API_KEY and AGENTISEND_BASE_URL. */
private readonly agentisend = new AgentiSend();
private mailFrom(): string {
const from = process.env.MAIL_FROM;
if (!from) throw new Error('Set MAIL_FROM to an address on a domain you have verified.');
return from;
}
async sendOrderConfirmation(email: string, orderId: string): Promise<string> {
const { id } = await this.agentisend.emails.send(
{
from: this.mailFrom(),
to: email,
subject: `Order ${orderId} confirmed`,
text: `Thanks for your order. Order ${orderId} is confirmed and the receipt is in your account.`,
},
// One order, one confirmation: a retried request replays the first send.
{ idempotencyKey: `order-confirmed/${orderId}` },
);
return id;
}
}The controller
/**
* NestJS — `POST /orders/confirmation`, which validates the body, calls the
* service, and turns a refusal into the same status with `code` and `fix`.
*/
import {
BadRequestException,
Body,
Controller,
HttpCode,
HttpException,
Inject,
Post,
} from '@nestjs/common';
import { AgentiSendError } from 'agentisend';
import { EmailService } from './email.service.js';
/** Enough to refuse an obvious typo here; the API checks the address properly. */
const ADDRESS = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@Controller('orders')
export class EmailController {
// `@Inject` names the provider explicitly, so injection also works under
// compilers that do not emit decorator metadata (esbuild, Vite, Bun).
constructor(@Inject(EmailService) private readonly email: EmailService) {}
@Post('confirmation')
@HttpCode(200)
async confirm(@Body() body: { email?: string; orderId?: string }): Promise<{ id: string }> {
const { email, orderId } = body ?? {};
if (!email || !ADDRESS.test(email) || !orderId) {
throw new BadRequestException('email and orderId are required');
}
try {
return { id: await this.email.sendOrderConfirmation(email, orderId) };
} catch (err) {
if (err instanceof AgentiSendError) {
throw new HttpException({ code: err.code, fix: err.fix }, err.status);
}
throw err;
}
}
}The module
/**
* NestJS — the module that wires the controller to the service. Nest's
* decorators need `reflect-metadata` loaded first, so it is the first import.
*/
import 'reflect-metadata';
import { Module } from '@nestjs/common';
import { EmailController } from './email.controller.js';
import { EmailService } from './email.service.js';
@Module({
controllers: [EmailController],
providers: [EmailService],
})
export class AppModule {}Start it
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
const app = await NestFactory.create(AppModule);
await app.listen(3000);curl -X POST localhost:3000/orders/confirmation \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","orderId":"A-1042"}'The key and its budget
Give the app a key of its own rather than the one you signed in with. POST /api-keys with sending_access mints a key that can send and read what it sent, and nothing else. PATCH /limits/keys/{id} puts a ceiling on it with budget_per_period and period; past the ceiling a send is refused with agent_budget_exceeded, and the controller answers with that code and its fix instead of retrying.
The idempotency key
Every send carries Idempotency-Key: order-confirmed/<orderId>, derived from the order and never from the moment. A retry after a timeout gets the first send's id back instead of confirming the same order twice.