Send an email
You end with a welcome email with typed templates, sent over SMTP after commit, tested and read in the dev inbox.
On this page
An email in Jadpo is a type with its fields, and your code emits one the
way it emits an event. It goes after the action commits, so a change that
is rolled back never sends anything. The compiler checks each template
against the type, and tests and jadpo dev catch the mail so nothing
leaves your machine. This guide sends a new member of a book club a
welcome email over SMTP.
You start from an app with a POST / route that runs an action called
join, the NewMember and Joined types it takes and answers with, and the
Member entity and migration they use.
Build an API shows how to write those. The first email
needs only Member, which this guide shows.
Say where mail goes
Mail leaves through a connection, and email: names the one every email
uses. Here that is the built-in SMTP driver, which talks to any mail
server you name. A language is needed too, since each email is worded in
one. Driver.smtp, email:, locales and Locale are built into Jadpo, and the
name mail is yours.
app.jadpo:
application { connections: { mail: { driver: Driver.smtp host: config.smtp_host credential: { username: config.smtp_username password: config.smtp_password } } } email: { connection: mail from: config.mail_sender }}locales { default: Locale.en_gb supported: [Locale.en_gb]}
The config. values are settings, each declared in config.jadpo. secret: true
keeps the password out of your code, and jadpo dev catches mail, so it never needs
a real one.
config.jadpo:
config { mail_sender: Email { default: "club@club.example" } smtp_host: Hostname { default: "mail.club.example" } smtp_password: Text { secret: true } smtp_username: Text { default: "club" }}
Send the smallest email
An email is a type built on EmailMessage, in its domain's emails/
folder. Its fields are the values its text uses. Every EmailMessage also has to:
and locale:, which you don't declare.
domains/:
/// Sent once someone has joined.type WelcomeEmail = EmailMessage { name: Text}
An email's subject lives in its domain's language file, with one file for each language. The entry with the email's own name is its subject line, and its fields are the placeholders.
domains/:
translations { welcome_email: "Welcome to the club, {name}"}
The email's body is a template beside its type, with its name.
domains/:
Welcome, {{ name }}See you at the next meeting.
An email goes out from an action that stores something, here a new member. Member
is the entity your app starts with, and Email, a field's type, is built into Jadpo.
If you are adding Member now, run jadpo migrate new add_members too, which writes
the migration that creates its table.
domains/:
/// A member of the book club.entity Member { id: Uuid address: Email name: Text { min_length: 1 max_length: 80 } policy: { create: [Access.public] }}
emit sends an email. It joins the action's transaction, so the email goes
only once the action has committed, and a failed send is retried on its
own.
domains/:
use club/entities { Member }use club/emails { WelcomeEmail }/// Adds a member, and welcomes them once that is done.action join(address: Member.address, name: Member.name) -> Member { var member: Member = Member.create { set: { address: address name: name } } emit WelcomeEmail { to: member.address locale: Locale.en_gb name: member.name } return member}
That is a working email. The rest of this guide gives it a link and an HTML version, and has a route send it.
Add a link and an HTML version
A link to the club's page comes from a setting, and goes in a new field.
config.jadpo, changed. club_url is new:
config { club_url: Url { default: "https://club.example" } mail_sender: Email { default: "club@club.example" } smtp_host: Hostname { default: "mail.club.example" } smtp_password: Text { secret: true } smtp_username: Text { default: "club" }}
domains/, changed. club_link is new:
/// Sent once someone has joined.type WelcomeEmail = EmailMessage { name: Text club_link: Url}
Write an HTML template beside the text one, and keep both, because jadpo check
refuses a mail with no plain-text part, as email.health_problem. Inside {{ }} a template
can use the email's fields, translations, if and for, and nothing else, and every
value is escaped.
domains/:
<h1>Welcome, {{ name }}</h1><p><a href="{{ club_link }}">See the next meeting</a></p>
domains/, changed:
Welcome, {{ name }}See the next meeting: {{ club_link }}
A template that names a field that doesn't exist is a compile error. So is a field no template or subject uses.
Text in a template is enough for one language. For a second, make each piece of it a
message, a type built on Message in the domain's translations/ folder, and call it
from the template, as {{ translations.club.welcome_heading(name: name) }}.
Translate your app's text declares a message, and
Email shows one in a template.
Send it from the route
POST / gives join a NewMember, and sends back a Joined.
domains/, changed. It takes and answers with those types, and
club_link: is new:
use club/entities { Member }use club/types { Joined, NewMember }use club/emails { WelcomeEmail }/// Adds a member, and welcomes them once that is done.action join(input: NewMember) -> Joined { var member: Member = Member.create { set: { address: input.address name: input.name } } emit WelcomeEmail { to: member.address locale: Locale.en_gb name: member.name club_link: config.club_url } return Joined { id: member.id name: member.name }}
Test it
Tests never send mail. They catch each email, rendered whole once its
action commits, so a template that can't render fails the test. A test
reads what has gone so far as email.sent.
domains/:
use club/types { Joined, NewMember }test "joining welcomes the new member" { var joined: Joined = join( NewMember { address: "ann@example.com" name: "Ann" } ) assert joined.name == "Ann" var sent: List<SentEmail> = email.sent assert list.count(sent) == 1 assert list.any( sent, item -> item.to == "ann@example.com" and item.subject == "Welcome to the club, Ann" ) assert list.all(sent, item -> list.contains(item.links, "https://club.example"))}
jadpo testtested: 1 passed, 1 action testAccept where the address goes
An email address is personal data, and this app now hands one to a mail
server it couldn't reach before. That is two things a person has to decide,
so jadpo check stops once it has a commit to compare with, which means a
second commit on main, or a branch. First write the record of the hosts your
app can reach:
jadpo records updatejadpo check
error a connection that can't erase the personal data it receives needs a person: mail
--> app.jadpo:3:9
|3 | mail: { | ^^^^ ErasureNotPossible.data_stays
- reason
- Erasing a person asks each provider that holds their data to erase it, and this one can't be asked. Here: connection
mailkeeps anEmailafter a person is erased: it is reached with a driver, so it has no package to declareerasure:. So a person says why the data stays: no acceptance listsmail: ErasureNotPossible.data_stays. - next
- Declare the package's
erasure:and name the connection'serasure: { key: }, or a person accepts it:jadpo acceptance new <purpose>writes the draft for them (human) - rule
- acceptance.
missing - help
- https://jadpo.
dev/docs/errors/acceptance. missing
error a new outside host the app can send data to needs a person: mail
--> network.jadpo_record:4:1
|4 | connection mail | ^^^^^^^^^^^^^^^ NetworkChange.host_added
- reason
- Data the app holds can now leave for a host it couldn't reach before. So a person accepts it: no acceptance this change adds lists
mail: NetworkChange.host_added. - next
- A person accepts it:
jadpo acceptance new <purpose>writes the draft for them (human) - rule
- acceptance.
missing - help
- https://jadpo.
dev/docs/errors/acceptance. missing
jadpo acceptance new writes the file listing both, and a person writes
the reason on the / line above it. An agent stops there and hands over,
because the reason has to come from you.
Get a person's sign-off goes through it step by
step.
jadpo acceptance new addresses_go_to_the_mail_serverWrote acceptances/20261104T1111_addresses_go_to_the_mail_server.jadpo mail ErasureNotPossible.data_stays mail NetworkChange.host_addeddecision: 2 changes wait for a person's reason --> acceptances/20261104T1111_addresses_go_to_the_mail_server.jadpo:1 next: Stop and hand over: a person reads the changes and writes why they are accepted on a `///` line above `acceptance`. (human)
The time in the file's name is yours. With the reason added,
acceptances/:
/// Our mail server keeps sent mail for 30 days and then deletes it, as our privacy notice says.acceptance { changes: { mail: ErasureNotPossible.data_stays mail: NetworkChange.host_added }}
jadpo check passes, and you commit the acceptance with the change.
With no commit yet, or exactly one commit on main and your changes uncommitted
on it, there is nothing to compare with. jadpo check then passes, and prints a
decision: note in place of these two errors. The errors appear with a second
commit or on a branch.
Read it in development
jadpo dev catches every email in a local inbox, so nothing leaves your
machine and the SMTP settings don't have to reach a real server.
jadpo devjadpo dev, started 11:11:44listening on http://localhost:3000 routes 2 routes settings 5 checked database disposable PostgreSQL, gone when dev stops inbox http://localhost:3001 · mail caught, never sentserving: saves reload, press ctrl-c to quit
In another terminal, join the club:
curl -i -X POST http://localhost:3000/members \ -H 'content-type: application/json' \ -d '{"address": "ann@example.com", "name": "Ann"}'
HTTP/1.1 201 Createdcontent-type: application/jsonx-request-id: req_c5fe488d0b27084acontent-length: 58date: Wed, 04 Nov 2026 11:11:46 GMT{"id":"01a1a642-0154-75c8-92e7-42dc081a74a4","name":"Ann"}
Open the inbox at http:/ to see each mail as HTML and as
text, or read it from the terminal, as a coding agent does:
jadpo email listmail email to subject health mail_1 WelcomeEmail ann@example.com Welcome to the club, Ann + clean health: An early indication only: it can't predict whether Gmail, Outlook or any other provider puts a mail in the inbox, since each uses its own filters and the sender's history. A clean score means the common mistakes are absent, nothing more.email: 1 mailnext: jadpo email show mail_1 shows its words, links and health
jadpo email show mail_1This is the start of what it prints. After it come the health checks' results and, when SpamAssassin is installed, its score.
mail_1 Welcome to the club, Ann email club.WelcomeEmail from club@club.example to ann@example.com at 2026-11-04T11:11:46.011Z trace fe35a94130f0acccd146bb13e6a4d8ca parts html 85 B · text 57 Btext Welcome, Ann See the next meeting: https://club.examplelinks 1 https://club.exampleHealth: all 7 of Jadpo's checks pass
Give every email the same look
Every email is wrapped in your app's layout when it has one, in
shared/. {{ content }} is the email's own HTML, and
Jadpo's own emails, such as the sign-in email, get the same wrapper.
shared/:
<html dir="{{ locale.direction }}"><head><title>{{ subject }}</title></head><body> <header>The book club</header> {{ content }}</body></html>
Send through a provider
app.jadpo is the only file that knows how mail leaves. To send through
Resend, install Jadpo's Resend package
and replace the mail connection with one that names it, and the email's type,
templates and emit stay as they are. Jadpo has no package for other mail
services, so for one of those use its SMTP server, or write a package for its
API. A package that declares an erasure: call, on a connection that
names the person's key, needs no ErasureNotPossible.data_stays acceptance,
because erasing a person then asks the provider to erase them too.
A File field on an email is an attachment, read from its store when the
email is sent.
Read more
Email, The SMTP driver, Built-in emails, The dev inbox and the health score, Email subjects and Erasure at providers.
Not built yet
- The inbox's link from a mail to its trace, and
jadpo trace, aren't built yet. A mail'straceline is an id you can look for in your logs. - The privacy emails Jadpo sends with data requests aren't built yet.
Markdown for agents: /guides/guide-send-an-email.md.Built from Jadpo main at aee2e8f. Jadpo's CI compiles this guide's code and runs its tests.