elements man email Read as markdownEmail is core to building most applications, and it's built into
Elements. Email templates are written in the same html language as
pages. The compiler inlines all the template's stylesheets into a single
<style> tag in <head> so styles render consistently across most mail
clients, and the runtime sends from any server context.
An email template is a regular html template with a /** @email */
build tag on its <html> tag. The build tag tells the compiler the
template is for email rather than for the browser. Email output is
server-only: there is no browser bundle, no <script> tags, no client
attach. All reachable CSS is concatenated into a single inline
<style> tag in <head>, and asset URLs are rewritten to absolute
form. The result is a self-contained html document that renders
correctly outside your app's domain, including in clients that strip
linked stylesheets or block external resources.
You can send email from any server function: route handlers, @rpc
functions, job run() methods, or any helper they call. The call is a
single function: email({ to, subject, body }).
Any SMTP-compatible provider works for the transport, configured once in
config.jsoc. The scaffolded config renders emails to the project
server log instead of sending them in development, so no SMTP
credentials are needed locally.
At a Glance
// app/shared/services/users.ts
import { email, sql } from "@elements/app";
import WelcomeEmail from "#app/emails/welcome";
interface SignupForm {
name: string;
email: string;
}
/** @rpc */
export function signup(form: SignupForm): User {
let user = sql<User>(`
insert into users (name, email) values (${form.name}, ${form.email}) returning *
`).firstOrThrow("insert returned no row");
email({
to: user.email,
subject: "welcome",
body: new WelcomeEmail({ name: user.name }),
});
return user;
}
email() participates in the async/await transform. There are two
exports, email and emailAsync. email(opts) is the sync-style call
you write; at build time the compiler rewrites it to
await emailAsync(opts) and propagates async up the callstack. The
runtime call is the async one; the source you write is the sync one.
Normally just call email(). Reach for emailAsync only when you need
an explicit promise (for example, Promise.all over a batch).
The send happens immediately when email() is called. The transport
opens a connection (or reuses one) and dispatches the message right
then.
Creating an Email Template
elements create email welcome
Scaffolds:
app/emails/welcome/
index.html
style.css
index.html looks like a regular template, with one difference: the
<html> tag has an @email build tag.
<!-- app/emails/welcome/index.html -->
import "./style.css";
/** @email */
<html (name: string)>
<h1>welcome, {name}</h1>
<p>thanks for signing up.</p>
</html>
The folder import gives you the email class:
import WelcomeEmail from "#app/emails/welcome";
new WelcomeEmail({ name: "alice" });
The class implements the EmailBody interface (toHtml(), toText()).
The scaffolded style.css imports app/shared/styles/email.css, the
site-wide email baseline:
/* app/emails/welcome/style.css */
@import "#app/shared/styles/email.css";
.email.welcome {
/* per-email styles */
}
app/shared/styles/email.css exists separately from the page styles
because email clients are not browsers. The page baseline (modern
selectors, flex/grid layout, <style> blocks) doesn't apply cleanly in
mail. app/shared/styles/email.css is the mail-safe baseline; per-email
tweaks live in the email's own style.css.
Sending
email() accepts a single options object.
import { email } from "@elements/app";
email({
to: user.email,
subject: "welcome",
body: new WelcomeEmail({ name: user.name }),
});
Options:
to:string | string[]. Required.from:string. Defaults toconfig.email.from.subject:string. Required.body: object withtoHtml()andtoText(). Typically an email template instance.cc:string | string[]. Optional.bcc:string | string[]. Optional.replyTo:string. Optional.headers:Record<string, string>. Optional.attachments:File[]. Optional.
email(opts) constructs the Email, sends it, and returns the
instance. The body's toHtml() and toText() are evaluated eagerly at
construction, so the rendered content reflects the state at the call
site.
For long-lived sends (newsletters, batch notifications), use a Job. The job retries on failure and gives you a typed payload.
Sending Alongside a Database Write
email() sends immediately. It is not transactional. Calling email()
inside tx() does not delay or condition the send on commit. If the
surrounding transaction rolls back after the send, the email has already
gone out.
For transactional semantics, send the email through a Job. The job row enqueue is part of the surrounding transaction. The send happens later, in a worker process, only after the transaction commits.
import { tx, sql } from "@elements/app";
import { SendWelcomeJob } from "#app/jobs/send-welcome";
/** @rpc */
export function signup(form: SignupForm): User {
return tx(() => {
let user = sql<User>(`insert into users (...) values (...) returning *`).firstOrThrow("insert returned no row");
new SendWelcomeJob({ to: user.email, name: user.name }).schedule();
return user;
});
}
If the transaction rolls back, the job row is never visible to workers. The email is never sent.
If the user changes their mind before the worker picks up the job,
Job.cancel(id) aborts a pending job:
import { Job } from "@elements/app";
let id = new SendWelcomeJob({ to, name }).schedule("in 1h");
let aborted = Job.cancel(id);
- Direct
email()for fire-and-forget that should send right now (password reset, magic link, immediate confirmation). - Job-wrapped for transactional, retryable, or cancellable sends (welcome, receipts, scheduled notifications).
Config
// config.jsoc
{
email: {
live: env<boolean>('EMAIL_LIVE', false),
from: 'no-reply@example.com',
replyTo: 'support@example.com',
host: env('SMTP_HOST', ''),
port: env<number>('SMTP_PORT', 587),
user: env('SMTP_USER', ''),
password: env('SMTP_PASSWORD', ''),
secure: false, // implicit TLS, port 465
startTls: true, // STARTTLS upgrade, port 587
timeout: '30s',
},
}
live: whentrue, send via SMTP. Whenfalse, render to the project server log instead of sending. The Elements default islive: true, but the scaffolded config overrides it by readingEMAIL_LIVEfrom the environment with a fallback offalse, so out of the box no real email is sent. You enable real sending by settingEMAIL_LIVE=truein the relevant env file (typicallyproduction.env).log: whether to write an audit log line on send. Default:truewhenlive: false,falsewhenlive: true.logFormat:'short'or'full'. Default:'full'whenlive: false,'short'whenlive: true.from: default From address used whenemail()doesn't set one.replyTo: default Reply-To.host,port: SMTP host/port. Required whenlive: true.user,password: SMTP auth. Optional.secure: implicit TLS (typical port 465).startTls: STARTTLS upgrade (typical port 587).timeout: connection timeout, e.g.'30s'.
live: false is rejected at startup in production. You can't
accidentally ship a config that logs emails instead of sending them.
Elements refuses to start.
The validator also rejects live: true without host or port, and
rejects unknown logFormat values. Configuration errors fail at
startup, not at the first send.
Development
The scaffolded config resolves to live: false in development (via the
EMAIL_LIVE fallback). Emails go to the logger, formatted to be
readable. You see the rendered subject, recipients, and body in the
project server log. No SMTP credentials needed for local development.
logFormat:
'full': full rendered email including headers and body.'short': one-line summary with subject and recipients.
When live: true, log is opt-in (log: true) to record each
successful send in the audit log.
Production
Set environment variables in production.env:
EMAIL_LIVE=true
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASSWORD=...
Any SMTP-compatible provider works.
Attachments
import { File, email } from "@elements/app";
let pdf = sql<{ data: Uint8Array; name: string }>(`select data, name from invoices where id = ${id}`).firstOrThrow("invoice not found");
email({
to: customer.email,
subject: `invoice ${id}`,
body: new InvoiceEmail({ amount, dueAt }),
attachments: [
new File({
name: pdf.name,
size: pdf.data.length,
contentType: "application/pdf",
data: pdf.data,
lastModified: new Date(),
}),
],
});
attachments is an array of File instances. It's the same File
class used for uploads. The File constructor takes every field:
name, size, contentType, data (a Uint8Array, straight from a
bytea column), and lastModified; there are no optional fields, so
supply them all.
The Email Class
If you want the rendered fields without sending immediately:
import { Email } from "@elements/app";
let e = new Email({
to: "alice@x.com",
subject: "hi",
body: new WelcomeEmail({ name: "alice" }),
});
console.log(e.html);
console.log(e.text);
e.send();
new Email(...) calls body.toHtml() and body.toText() eagerly. The
instance reflects the state at construction.
e.send() routes through the same EmailServer as email(). Both
paths share the configured transport.
The EmailBody Interface
Anything with toHtml() and toText() qualifies as an email body.
Email templates implement this automatically. A custom class works too:
class PlainText {
constructor(private message: string) {}
toHtml(): string { return `<pre>${this.message}</pre>`; }
toText(): string { return this.message; }
}
email({ to, subject: "note", body: new PlainText("hello there") });
What to Put in the Email HTML
Email clients are closer to browsers than they used to be, but a few details matter:
-
Styles. Import CSS files the way you would in any template (
@import "./style.css", which itself importsapp/shared/styles/email.css). Elements concatenates all reachable CSS in build order into the single<style>tag the compiler emits in<head>. Modern mail clients render it fine. -
Tables for complex layout. Flexbox and grid support is uneven across clients; tables remain the safest layout primitive.
-
Absolute image URLs. Relative URLs don't resolve in mail clients.
-
Text version. Elements generates one from your html. Override
toText()if you want to control it.
Related
jobs: transactional or cancellable email sends belong in a Job.html: email templates use the same html language with an@emailbuild tag.