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

Built-in emails

On this page

Jadpo sends some emails itself: the magic-link email (Magic links) and the privacy emails (Data requests). Each works with nothing written, and your app can change as much of it as it needs, in five levels. Each level keeps the ones below it.

Every email, built in or your app's own, goes through your app's one email: setting in application { }. It names a connection that provides email: the built-in SMTP driver or any package that provides it.

example
// app.jadpoapplication {    connections: {        postmark: {            package: Postmark.v1            credential: config.postmark_token        }    }    email: {        connection: postmark        from: config.mail_sender    }}

magic_link: with no email: setting is a compile error, unless it sends its own email (level 5).

Level 1: nothing

Jadpo renders its own email, HTML and text, in the person's language, with Jadpo's wording (Jadpo's own magic-link email). Jadpo ships its wording in en_gb and en_us, and releases add locales. If your app supports a locale Jadpo doesn't ship, you supply the wording (level 2). Until you do, jadpo check fails with one error listing every Jadpo message the locale is missing, unless the locale is in_progress:, which falls back as any translation does (Languages).

Level 2: change the words

You write Jadpo's messages in the root translations/<locale>.jadpo, under jadpo: { } (Jadpo's and packages' text):

example
// translations/en_gb.jadpotranslations {    jadpo: {        magic_link_email: "Your link to sign in to {app_name}"        magic_link_intro: "Someone asked to sign in to {app_name} as {address}."        sign_in_codes_disabled: "Too many wrong codes today. Use the link in your email instead."    }}

You can reword only the messages of features your app has switched on. Magic-link wording without magic_link: is an error saying the feature is off. jadpo translations builtin lists the messages of the features your app has on, with their fields. Jadpo shows no pages, so its wording is for emails and failures only.

Every Jadpo message may also use {app_name}, the application's display_name: (The application's name). An app that needs the name translated rewords the messages.

The magic-link email uses these messages:

Message Fields en_gb wording
magic_link_email (the subject) "Sign in to {app_name}"
magic_link_heading "Sign in"
magic_link_intro address: Email "Someone asked to sign in as {address}."
magic_link_button "Sign in"
magic_link_code_line code: SignInCode "Or enter this code: {code}"
magic_link_expiry_line expires_in: PresentationText "The link and the code work for {expires_in}."
magic_link_ignore_line "If you didn't ask to sign in, you can ignore this email."
sign_in_link_expired "This link has expired or has been used. Ask for a new one."
sign_in_link_other_browser "Open this link in the browser where you asked for it, or enter the code instead."
sign_in_code_invalid "That code isn't right. Check it, or ask for a new one."
sign_in_codes_disabled "Too many wrong codes today. Use the link in your email instead."
sign_in_refused "We couldn't sign you in."
sign_in_return_invalid (only with generate_routes: false, Signing in from code the app writes) "We can't send you back there after signing in. Start again from this site."

expires_in is expires_after: as Jadpo formats it in the email's language: "15 minutes". The failures' wording also goes into the per-locale failure export for client apps (Failures for client apps).

These twelve messages are in Jadpo's catalogue, with the wording above. They are on once you declare magic_link:. Then they are listed, required in your app's other locales and rewordable, the email is written with them, and the failures' wording is in the per-locale failure export.

Passkeys bring no email, only the wording of their failures, which a client app shows. These messages are on, listed, required in your app's other locales and rewordable once you declare passkey::

Message en_gb wording
passkey_challenge_expired "That took too long. Try your passkey again."
passkey_rejected "We couldn't sign you in with that passkey. Try another passkey, or sign in with your email."
passkey_already_registered "This passkey is already set up."
passkey_limit_reached "You have as many passkeys as an account can hold. Remove one to add another."
passkey_not_found "That passkey isn't on your account."

Level 3: branding

Every email, built in or your app's own, is wrapped in your app's shared email layout, so built-in emails carry your branding with no other change. The layout is optional, and an app has at most one, in shared/emails/layout.html and shared/emails/layout.txt. Without it, Jadpo's plain layout is used. The layout is checked under the same strict rules as templates, may call translations, and receives:

In the layout What it is
{{ content }} The rendered body: the safe-HTML type in layout.html, text in layout.txt
{{ subject }} The email's subject line, PresentationText
{{ locale.direction }} ltr or rtl (Locales)
{{ app.display_name }} The application's display_name:
example
<html dir="{{ locale.direction }}"><head><title>{{ subject }}</title></head><body>  <header>{{ app.display_name }}</header>  {{ content }}</body></html>

The layout wraps each of your app's emails: its .html in layout.html and its .txt in layout.txt. You can keep either file without the other. {{ app.display_name }} without a display_name: is email.settings_invalid.

The application's name

application { display_name: "Acme" } in app.jadpo, plain text, is required once any built-in email is switched on, such as magic links or the privacy emails. Without it, jadpo check names the feature that needs it. Templates read it as {{ app.display_name }}, and Jadpo's own wording as {app_name}.

Level 4: your own email

magic_link: { email: accounts.SignInEmail } names an EmailMessage type of your app's, which Jadpo fills and sends instead of its own. The type takes its fields from the built-in MagicLinkEmail with pick:, and may have no others, since Jadpo fills it:

MagicLinkEmail field Type Holds
address Email The address the link was asked for; also the email's to:
sign_in_link Url The link to the frontend's link: path, carrying the token
code SignInCode The six-digit code, text so a leading 0 stays
expires_in PresentationText How long both work, formatted by Jadpo in the email's language

Every EmailMessage also has to: and locale: (Sending). Jadpo sets the locale as it does for its own email (Magic links).

Here the app's sign-in email picks three fields. In the domain's language file, sign_in_email is the email's subject line:

example
// domains/accounts/emails/sign_in_email.jadpo/// The sign-in email, in the app's own words and design.type SignInEmail = EmailMessage {    pick: [MagicLinkEmail.sign_in_link, MagicLinkEmail.code, MagicLinkEmail.expires_in]}// domains/accounts/translations/en_gb.jadpotranslations {    sign_in_email: "Your Notes sign-in link"    sign_in_heading: "Welcome back to Notes"    sign_in_button: "Open Notes"    sign_in_code_line: "Or type {code} on the sign-in page."    sign_in_expiry: "Both work for {expires_in}."}// authentication.jadpoauthentication {    ...    sign_in: {        magic_link: {            email: accounts.SignInEmail        }    }}

Its HTML template, domains/accounts/emails/sign_in_email.html:

text
<!-- domains/accounts/emails/sign_in_email.html --><h1>{{ translations.accounts.sign_in_heading() }}</h1><p><a href="{{ sign_in_link }}">{{ translations.accounts.sign_in_button() }}</a></p><p>{{ translations.accounts.sign_in_code_line(code: code) }}</p><p>{{ translations.accounts.sign_in_expiry(expires_in: expires_in) }}</p>

The messages it calls, such as SignInHeading and SignInCodeLine, are the domain's own Message types, one to a file in translations/. Its .html and .txt templates sit beside it in emails/, and its subject is its entry in the domain's language files. All of it follows the strict template rules, and it is wrapped in the shared layout like any of your app's emails. Your app's code never emits MagicLinkEmail. Your own magic-link email says how Jadpo fills and sends it.

Level 5: your own sending

magic_link: { send: accounts.send_sign_in_link } names an action that sends the email itself, through any package, instead of Jadpo sending one. Its signature is fixed: it takes one built-in MagicLinkRequest and returns Unit.

MagicLinkRequest field Type
address Email
sign_in_link Url
code SignInCode
expires_at Instant
locale Locale

Here messages is a connection to a package of the app's choice:

example
// domains/accounts/actions/send_sign_in_link.jadpoaction send_sign_in_link(request: MagicLinkRequest) fails MessageNotSent -> Unit {    attempt messages.send(...)}
  • The action runs after the request commits, like a subscriber, and is retried under the same rules (Retries and giving up). Its failures are a subscriber's.
  • Each attempt is given a link and a code of its own, which ends the last attempt's. So its provider calls go under new keys each attempt, and keep no record of their steps: a provider that replays a key's first answer still sends the link that works.
  • In development its provider calls are faked and logged, as every provider call is (Provider calls in development). The dev inbox doesn't show the mail, since Jadpo sends none.
  • Levels 4 and 5 can't be combined: email: and send: on one email is a compile error.

Every built-in email

The same five levels apply to each email Jadpo sends. A feature with one email takes email: or send: directly. A feature with several takes emails: { }, with one entry for each email, and each entry takes email: or send:, never both. Delivery is always through the one email: connection.

Email Built-in types Sent when Configured in
Magic link MagicLinkEmail, MagicLinkRequest POST /auth/magic-link (Magic links) authentication { sign_in: { magic_link: { email: / send: } } }
Address check PrivacyAddressCheckEmail, PrivacyAddressCheckRequest A person asks through POST /privacy/requests (Data requests) privacy: { emails: { address_check: { } } }
Export ready PrivacyExportEmail, PrivacyExportRequest PrivacyExportReady (Export) privacy: { emails: { export_ready: { } } }
Erasure confirmed, with its cancel link PrivacyErasureConfirmedEmail, PrivacyErasureConfirmedRequest PrivacyErasureScheduled (Erasure) privacy: { emails: { erasure_confirmed: { } } }
Erasure completed, with what was kept PrivacyErasureCompletedEmail, PrivacyErasureCompletedRequest PrivacyErasureCompleted (Erasure) privacy: { emails: { erasure_completed: { } } }
example
// app.jadpoapplication {    display_name: "Acme"    privacy: {        self_service: {}        emails: {            export_ready: {                email: privacy_mail.ExportEmail            }            erasure_confirmed: {                send: privacy_mail.send_erasure_confirmation            }        }    }}

Jadpo fills the privacy emails' fields. Every one has address: Email, which is also its to:, and the locale: of the request. Each request type, for send:, has the same fields, with times as Instant in place of text:

Email type Its other fields Request type's other fields
PrivacyAddressCheckEmail confirm_link: Url, expires_in: PresentationText confirm_link: Url, expires_at: Instant
PrivacyExportEmail download_link: Url, expires_in: PresentationText (7 days) download_link: Url, expires_at: Instant
PrivacyErasureConfirmedEmail erases_on: PresentationText, cancel_link: Url erases_at: Instant, cancel_link: Url
PrivacyErasureCompletedEmail kept: List<PrivacyKeptRecord> kept: List<PrivacyKeptRecord>

PrivacyKeptRecord has two fields:

  • name: PresentationText, the kept record's wording. It is the entity's entry in its domain's language files, which every entity marked Erasure.retained must have.
  • kept_until: PresentationText, the end of its retention, in the email's language.

A template walks kept with for. The address-check link lasts 15 minutes, as a magic link does.

Markdown for agents: /docs/built-in-emails.md.Built from Jadpo main at a707efb.