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.
// 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):
// 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: |
<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:
// 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:
<!-- 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:
// 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:andsend: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.
| 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: { } } } |
// 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 markedErasure.retainedmust 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.