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 Member entity and a POST / route
that runs an action called join.
Store and change a record and
Build an API show how to write those.
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.
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]}
config.jadpo:
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" }}
Declare the 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 club_link: Url}
Word it
An email's words live in its domain's language file, so a second language needs no change to the templates. The entry with the email's own name is its subject line, and its fields are the placeholders.
Each other piece of wording is a message, a type built on Message whose
fields are its placeholders.
domains/:
/// The heading of the welcome email.type WelcomeHeading = Message { name: Text}
domains/:
/// The welcome email's link to the club's page.type NextMeeting = Message {}
domains/:
translations { next_meeting: "See the next meeting" welcome_email: "Welcome to the club, {name}" welcome_heading: "Welcome, {name}"}
Write the templates
The templates sit beside the type and take its name. Write both an HTML
one and a text one, because a mail with no plain-text part is one of the
things jadpo check warns about. Inside {{ }} a template can use the
email's fields, translations, if and for, and nothing else, and every
value is escaped.
domains/:
<h1>{{ translations.club.welcome_heading(name: name) }}</h1><p><a href="{{ club_link }}">{{ translations.club.next_meeting() }}</a></p>
domains/:
{{ translations.club.welcome_heading(name: name) }}{{ translations.club.next_meeting() }}: {{ club_link }}
A template that names a field or a message that doesn't exist is a compile error. So is a field no template or subject uses.
Send it
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/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 once your app has a commit to compare with, jadpo check stops. 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. A
project with no commit yet gets a note in place of these two errors, saying
the connection is refused from the next change.
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
Postmark, Amazon SES or Resend, replace the mail connection with one that
names a mail package, and the email's type, templates and emit stay as
they are. 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 94ab778. Jadpo's CI compiles this guide's code and runs its tests.