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

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: 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. 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 imports app/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 @email build tag.