Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 30 Oct 2026. What's built · Install 0.1.0
jadpo
Reference: Built-in services

Built-in services

Email

On this page

Most apps send a handful of emails: confirm your address, an invoice, a welcome. Each is a type, type X = EmailMessage, whose fields are the values its text uses. It lives in its domain's emails/ folder, beside its HTML and text templates. Its subject line is its entry in the domain's language files, with its fields as placeholders (Email subjects).

The templates' words come from the domain's translations (Translations as messages). A template calls {{ translations.<domain>.<message>(...) }}, which takes the email's locale, and the compiler checks every call and placeholder against the message types and the email's fields.

Here is a welcome email, from its type to the action that sends it. The app sends mail over SMTP, its settings and User entity are hidden, and the email's type is:

example
use accounts/types { DisplayName }/// Sent once a user has signed up.type WelcomeEmail = EmailMessage {    display_name: DisplayName    sign_in_link: Url}

Its heading is one of the domain's messages, and the domain's language file words it. There, welcome_email is the email's subject line:

example
use accounts/types { DisplayName }/// The heading of the welcome email.type WelcomeHeading = Message {    name: DisplayName}
example
translations {    sign_in: "Sign in"    welcome_email: "Welcome, {display_name}"  // the subject line    welcome_heading: "Welcome, {name}"}

The .html template, domains/accounts/emails/welcome_email.html, sits beside the type:

example
<html dir="{{ locale.direction }}"><h1>{{ translations.accounts.welcome_heading(name: display_name) }}</h1>{{ include button(label: translations.accounts.sign_in(), url: sign_in_link) }}</html>

Templates

An email has an .html template, a .txt one or both, named after its type. An email with neither is email.template_missing, and so is a template that no email type is named after.

Inside {{ }} a template writes Jadpo's own expressions:

  • its email's fields, to and locale among them;
  • {{ if (...) }} ... {{ else if (...) }} ... {{ else }} ... {{ end }} on a Bool;
  • {{ for item in list }} ... {{ end }};
  • translations and text.format, both written in the email's locale without a locale:;
  • {{ locale.direction }}, the locale's text direction in Jadpo's wording, ltr or rtl, for a dir= attribute.

What a template may not do

Templates are strict. Each of these is a compile error:

  • a name that isn't a field of the email's type;
  • a message that doesn't exist, or a placeholder that is missing or of the wrong type;
  • a partial that doesn't exist or is given the wrong fields;
  • if on something that isn't a Bool, and for over something that isn't a list;
  • any call other than translations and formatting, and arithmetic or building a value (email.template_not_allowed).

A field that no template reads, and no wording of the subject writes, is email.field_unused. It is a judgement call, so a decline line above the field answers it (Declining a judgement call).

Partials: pieces several emails share

A partial is a piece several emails share, such as a button or a signature. You declare it as type Name = EmailPartial { ... }, with its .html and .txt beside it as an email's are:

  • in shared/emails/, where every domain's emails may include it;
  • or in a domain's own emails/, where only that domain's emails may.

A template includes one as {{ include name(field: value, ...) }}, giving each of its fields once, by name. An HTML template includes the partial's .html, and a text one its .txt. The partial is written in the locale of the template that includes it, which it gets as its locale. Its templates follow the same strict rules, and may include other partials, but never lead back to themselves. A partial that doesn't exist, can't be reached, lacks the template it is included from, or is given the wrong fields is email.partial_invalid.

Here is the button the welcome email includes:

example
/// A link set as a button, in every email that asks the reader to act.type Button = EmailPartial {    label: PresentationText    url: Url}
example
<a href="{{ url }}" style="display:inline-block;padding:12px 24px;background-color:#1f2937;color:#ffffff;text-decoration:none;">{{ label }}</a>

What a template writes out

Only text and numbers are written out, in templates as in placeholders. {{ value }} takes Text, PresentationText, Int or Decimal, with numbers written the locale's way, or an enum whose values have wording (Enums with wording). Writing anything else out is a compile error that says how to format it (email.template_value_invalid):

  • an Instant or Money, with text.format (Every standard type meets one contract);
  • a Bool, as a condition for if;
  • a list, walked with for;
  • an object, by writing its fields one by one;
  • an enum without wording, by adding wording.

Values are escaped. HTML goes in unescaped only as the safe-HTML type Html (Text formats), which is safe because of how it is built.

Sending

You send an email by emitting it. It goes after the action commits, and is retried like any delivery (Delivery: at least once, safe to repeat), so a sign-up that is rolled back never sends a welcome. Every EmailMessage has to: and locale:.

example
use accounts/entities { User }use accounts/types { DisplayName }use accounts/emails { WelcomeEmail }/// Signs someone up, and welcomes them once it is done.action sign_up(email: Email, display_name: DisplayName, chosen_locale: Locale) -> User {    var user: User = User.create {        set: {            email: email            display_name: display_name            locale: chosen_locale        }    }    emit WelcomeEmail {        to: user.email        locale: user.locale        display_name: user.display_name        sign_in_link: config.app_url    }    return user}

The email is rendered whole when it is sent. Each template is run with the email's fields and wrapped in the shared layout (Built-in emails, level 3), the subject is worded in the email's locale, and each File field is read from its store as an attachment. The connection is handed that message, and the delivery's record alone says it went.

Where it goes: email:

Mail goes through the email capability (Capabilities). email: { connection: postmark from: config.mail_sender } in application { } names a connection, and every email goes through it, your app's and Jadpo's own (Built-in emails).

  • SMTP is a built-in driver, driver: Driver.smtp (The SMTP driver). On Cloudflare Workers it uses port 587, since Workers blocks port 25.
  • A mail package, such as Postmark, Amazon SES or Resend, is the other way.

The EmailMessage types, the translations and emit are the same whichever you choose, so changing provider changes only app.jadpo.

Attachments

A File field on an EmailMessage is an attachment. It is read from its store when the email is sent, and checked against the size limit the mail package declares. Through a mail package, each File field needs a max_size:, and an email whose fields' sizes add up to more than the package's limits: { attachments: } is email.attachment_limit. A provider that can't send attachments can still be an ordinary package, but can't be the email: connection.

What belongs to the mail package

Everything specific to one provider is the package's own:

  • Per-send values, such as Postmark's message streams or SES configuration sets, are typed settings on the connection.
  • Hosted templates with variables (postmark.email.send_with_template(...)), suppression lists and marketing sends are the package's operations. You call them from an action or subscriber in a domain the connection lists. One connection serves both: email: uses it, and the listed domains call it. Mail sent from a hosted template gets no dev inbox entry, no health score and no automatic template check, though your app can add a contract case.
  • Bounces and complaints are the package's own webhook events, which you subscribe to like any package event. There are no standard EmailBounced and EmailComplained events, so moving to another provider means rewriting the bounce subscribers.

A send whose outcome is unknown

Postmark, SES, SendGrid and Mailgun take no idempotency key, so a send that may or may not have gone out can't safely be repeated. Such a send is parked for an operator and never resent blindly, since a duplicate receipt isn't harmless (Retries and giving up). jadpo deliveries can resend in bulk.

Email in tests

Tests catch mail instead of sending it. jadpo test catches every email, rendered whole once its action commits, so a template that can't be rendered fails the test. A test reads what it has sent so far as email.sent, a List<SentEmail>. Each has its to, from, subject, html and text (none without that template), every link in it (links), its attachments' file names (attachments), and the sign-in email's token and code (sign_in_token, sign_in_code). Only a test can read email.sent (email.sent_context). The mail is caught whole whichever connection email: names, a mail package's included.

This test signs someone up and checks the welcome email:

example
use accounts/entities { User }test "signing up welcomes the new user, with a link to sign in" {    var user: User = sign_up("ada@notes.example", "Ada", Locale.en_gb)    assert user.display_name == "Ada"    var sent: List<SentEmail> = email.sent    assert list.count(sent) == 1    assert list.any(sent, item -> item.to == "ada@notes.example" and item.subject == "Welcome, Ada")    assert list.all(sent, item -> list.contains(item.links, "https://notes.example"))}

Email in development: the dev inbox

jadpo dev catches every email in a local inbox, so nothing leaves your machine (The dev inbox and the health score). You open it in the browser, on a page jadpo dev serves beside the app. Each mail is shown as HTML and as text, in its locale, with its links working: a magic link opens the frontend's page, which signs in locally. Each mail also names the trace of the request that sent it.

An agent uses the same inbox from the command line, with jadpo email list, jadpo email show <id> --json, jadpo email links <id> and jadpo email open <id> <link>, so it can follow a sign-up or a privacy request from start to finish. jadpo email clear empties the inbox.

The mail health score

Every mail in the dev inbox, and jadpo email show <id>, shows what Jadpo's own checks found and how to fix each problem: no plain-text part, content that is only images, broken HTML, links that aren't absolute HTTPS, link shorteners, a subject in capitals, a missing unsubscribe header on bulk mail, and more than 102 KB, where Gmail clips a mail. jadpo check reports templates that would fail as email.health_problem, an error you can decline (Declining a judgement call). When SpamAssassin is installed, its open rules are added (SpamAssassin in the health score). jadpo email check-domain compares the sending domain's SPF, DKIM and DMARC records with what the mail connection needs.

The score is an early indication, not a guarantee, and every screen that shows one says so. It can't predict whether Gmail, Outlook or any other provider puts a mail in the inbox, because they use their own filters and the sender's history, which change constantly. A clean score means the common mistakes are absent, nothing more, so don't rely on it. Testing where mail really lands needs seed-account services outside Jadpo.

Email and personal data

The personal-data report lists the mail connection as receiving every marked field a mail carries. A mail package that declares an erasure: operation is called when a person is erased (Erasure at providers).

Markdown for agents: /docs/email.md.Built from Jadpo main at a707efb.