Manual Email

Email

elements man email Read as markdown

Email 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 the styles travel inside the message, 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 every root-relative URL (an asset, or a link like href="/jobs/42/pay", static or bound) is prefixed with the app's URL for the active environment (getAppUrl(): the configured domain, or http://localhost:<port> in development). 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

Terminal
elements create email welcome

Scaffolds:

Directory layout
app/emails/welcome/ index.ehtml style.css

index.ehtml looks like a regular template, with one difference: the <html> tag has an @email build tag.

app/emails/welcome/index.ehtml
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:

Usage
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.

Example
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 to config.email.from.
  • subject: string. Required.
  • body: object with toHtml() and toText(). 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

Two separate reasons put an email in a job.

Off the request, with retries. email() waits on the mail server. In an @rpc or route that wait holds up the request, and a mail server outage surfaces as an error in that call. A job sends from a worker process and retries until it succeeds.

Tied to the commit. To send only once the transaction commits, schedule the job inside tx(). The job is a row in that transaction, so a throw anywhere in it rolls the job back with everything else, and the email goes out only after the commit. email() itself talks to the mail server the moment it is called, so an email() inside tx() has already gone out if a later statement rolls the transaction back.

app/shared/services/users.ts
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(); createDefaultWorkspace(user); return user; }); }

If createDefaultWorkspace throws, the transaction rolls back, the job row is never visible to workers, and 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:

Example
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: when true, send via SMTP. When false, render to the project server log instead of sending. The Elements default is live: true, but the scaffolded config overrides it by reading EMAIL_LIVE from the environment with a fallback of false, so out of the box no real email is sent. You enable real sending by setting EMAIL_LIVE=true in the relevant env file (typically production.env).
  • log: whether to write an audit log line on send. Default: true when live: false, false when live: true.
  • logFormat: 'short' or 'full'. Default: 'full' when live: false, 'short' when live: true.
  • from: default From address used when email() doesn't set one.
  • replyTo: default Reply-To.
  • host, port: SMTP host/port. Required when live: 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, with the subject, recipients, and body. No SMTP credentials needed for local development.

Where the log lands depends on what sent it. An email sent from a route or an @rpc shows in the elements start output and in .elements/logs/program.log. One sent from a job's run() is sent by a worker process, so it lands in .elements/logs/job.log.

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.

Previewing an Email in the Browser

An email template is a template, so a route can return one and the browser renders it as a page: the same inlined <style>, the same markup a mail client receives. That is the quickest way to look at an email while you work on it. Edit the template and reload.

app/routes/email-preview.ts
import { Request } from "@elements/app"; import InvoiceReadyEmail from "#app/emails/invoice-ready"; export default function route(req: Request) { return new InvoiceReadyEmail({ name: "Ada", amount: "$249.00", payUrl: "/jobs/1/pay", }); }

Register it in development only. process.env.ENV names the active environment (elements man config):

index.ts
if (process.env.ENV === "development") { app.route("/dev/emails/invoice-ready", emailPreview); }

The attributes are sample values, so the page needs no database rows. In the preview, links stay root-relative, which resolves against the page you are on; the sent email gets them absolute. To preview an email with real data, load the row in the route the same way the sending code does.

To check what an email says in a test, build it with new Email(...) (below) and read e.to, e.subject, e.html, and e.text without sending it.

Production

Set environment variables in production.env:

production.env
EMAIL_LIVE=true SMTP_HOST=smtp.sendgrid.net SMTP_PORT=587 SMTP_USER=apikey SMTP_PASSWORD=...

Any SMTP-compatible provider works.

Attachments

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

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

Example
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 imports app/shared/styles/email.css). Elements concatenates all reachable CSS in build order into the single <style> tag the compiler emits in <head>.

  • 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 @email build tag.