Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 4 Nov 2026. What's built · Install 0.2.0
jadpo
Guides: all 17

Guides

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 /members 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:

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:

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/club/emails/welcome_email.jadpo:

jadpo
/// 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/club/translations/welcome_heading.jadpo:

jadpo
/// The heading of the welcome email.type WelcomeHeading = Message {    name: Text}

domains/club/translations/next_meeting.jadpo:

jadpo
/// The welcome email's link to the club's page.type NextMeeting = Message {}

domains/club/translations/en_gb.jadpo:

jadpo
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/club/emails/welcome_email.html:

jadpo
<h1>{{ translations.club.welcome_heading(name: name) }}</h1><p><a href="{{ club_link }}">{{ translations.club.next_meeting() }}</a></p>

domains/club/emails/welcome_email.txt:

jadpo
{{ 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/club/actions/join.jadpo:

jadpo
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/club/actions/join.test.jadpo:

jadpo
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"))}
shell
jadpo test
output
tested: 1 passed, 1 action test

Accept 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:

shell
jadpo records updatejadpo check
jadpo checkcheck failed: 2 errors, 12 files

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 mail keeps an Email after a person is erased: it is reached with a driver, so it has no package to declare erasure:. So a person says why the data stays: no acceptance lists mail: ErasureNotPossible.data_stays.
next
Declare the package's erasure: and name the connection's erasure: { 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.

shell
jadpo acceptance new addresses_go_to_the_mail_server
output
Wrote 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/20261104T1111_addresses_go_to_the_mail_server.jadpo:

jadpo
/// 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.

shell
jadpo dev
output
jadpo 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:

shell
curl -i -X POST http://localhost:3000/members \  -H 'content-type: application/json' \  -d '{"address": "ann@example.com", "name": "Ann"}'
text
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://localhost:3001 to see each mail as HTML and as text, or read it from the terminal, as a coding agent does:

shell
jadpo email list
output
  mail    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
shell
jadpo email show mail_1

This is the start of what it prints. After it come the health checks' results and, when SpamAssassin is installed, its score.

output
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/emails/layout.html. {{ content }} is the email's own HTML, and Jadpo's own emails, such as the sign-in email, get the same wrapper.

shared/emails/layout.html:

jadpo
<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's trace line 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.