Sending branded email

Aha! Builder

Aha! Builder applications can send HTML email. Your application defines branded email templates, previews them in the Builder UI with sample data, and sends them from server code — order confirmations, notifications, reminders. Elle builds the template and the sending logic when you describe an email your application should send.

This reference documents the email API: defining templates, the sendEmail options, rate limits, and HTML guidelines for reliable rendering across email clients. Read it to see what your applications can send, to review what Elle generated, or to ground an AI assistant in the specifics — reference this article with Elle or any LLM so it knows exactly how email works in Aha! Builder.

Click any of the following links to skip ahead:

Overview

The email package provides APIs for sending HTML emails from server functions using email templates. Templates are defined in the emails/ directory, keeping rendering logic separate from server functions. The Builder UI provides a live preview of templates with sample data. If the user asks for email functionality, they almost certainly want an email with an HTML template that matches the branding of their app.

Top

Email templates

Email templates define the rendering logic for an email in a dedicated file. Each template exports a render function and registers itself with server.emailTemplate() so it appears in the Builder UI for preview.

Defining a template

// emails/orderConfirmation.ts
import { server } from '@aha-app/builder-core';

export function renderOrderConfirmation(data: {
customerName: string;
orderNumber: string;
items: { name: string; qty: number; price: number }[];
total: number;
}) {
const itemRows = data.items
.map(
item => `
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-family: Arial, sans-serif; font-size: 14px; color: #333;">
${
item.name
}
</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-family: Arial, sans-serif; font-size: 14px; color: #333; text-align: center;">
${
item.qty
}
</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-family: Arial, sans-serif; font-size: 14px; color: #333; text-align: right;">$
${item.price.toFixed(
2
)}
</td>
</tr>
`

)
.join('');

return `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin: 0; padding: 0; background-color: #f4f4f4;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden;">
<tr>
<td style="background-color: #2563eb; padding: 24px 32px;">
<h1 style="margin: 0; font-family: Arial, sans-serif; font-size: 22px; color: #ffffff;">Order confirmed</h1>
</td>
</tr>
<tr>
<td style="padding: 32px;">
<p style="font-family: Arial, sans-serif; font-size: 16px; color: #333; margin: 0 0 16px;">
Hi
${data.customerName},
</p>
<p style="font-family: Arial, sans-serif; font-size: 14px; color: #555; margin: 0 0 24px;">
Your order <strong>#
${
data.orderNumber
}
</strong> has been confirmed.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom: 24px;">
<tr>
<th style="padding: 8px 0; border-bottom: 2px solid #ddd; font-family: Arial, sans-serif; font-size: 12px; color: #888; text-transform: uppercase; text-align: left;">Item</th>
<th style="padding: 8px 0; border-bottom: 2px solid #ddd; font-family: Arial, sans-serif; font-size: 12px; color: #888; text-transform: uppercase; text-align: center;">Qty</th>
<th style="padding: 8px 0; border-bottom: 2px solid #ddd; font-family: Arial, sans-serif; font-size: 12px; color: #888; text-transform: uppercase; text-align: right;">Price</th>
</tr>
${itemRows}
<tr>
<td colspan="2" style="padding: 12px 0; font-family: Arial, sans-serif; font-size: 16px; font-weight: bold; color: #333;">Total</td>
<td style="padding: 12px 0; font-family: Arial, sans-serif; font-size: 16px; font-weight: bold; color: #333; text-align: right;">$
${data.total.toFixed(
2
)}
</td>
</tr>
</table>
<table cellpadding="0" cellspacing="0">
<tr>
<td style="background-color: #2563eb; border-radius: 6px; padding: 12px 24px;">
<a href="https://example.com/orders/
${
data.orderNumber
}
" style="font-family: Arial, sans-serif; font-size: 14px; color: #ffffff; text-decoration: none; font-weight: bold;">View order</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 16px 32px; background-color: #f9fafb; border-top: 1px solid #eee;">
<p style="font-family: Arial, sans-serif; font-size: 12px; color: #999; margin: 0;">
You received this email because you placed an order. If you have questions, reply to this email.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`
;
}

server.emailTemplate('orderConfirmation', {
name: 'Order confirmation',
subject: 'Order confirmed',
preview: () => ({
to: 'customer@example.com',
data: {
customerName: 'Jane',
orderNumber: '12345',
items: [
{ name: 'Widget', qty: 2, price: 19.99 },
{ name: 'Gadget', qty: 1, price: 9.99 },
],
total: 49.97,
},
}),
render: renderOrderConfirmation,
});

Template options

Option

Type

Required

Description

name

string

No

Display name shown in the Builder UI (defaults to key)

subject

string

Yes

Email subject line

preview

() => { to?: string; data: Record<string, unknown> }

Yes

Returns mock data for the Builder UI preview

render

(data: any) => string

Yes

Returns the HTML email body

Using a template in a server function

Import the render function and pass its output to sendEmail:

// pages/checkout.server.ts
import { server, sendEmail, currentUser } from '@aha-app/builder-core';
import { renderOrderConfirmation } from '../emails/orderConfirmation';

server.data('confirmOrder', async ({ orderNumber, items, total }) => {
const user = currentUser();

sendEmail({
to: user.email,
subject: `Order #${orderNumber} confirmed`,
contentType: 'text/html',
body: renderOrderConfirmation({
customerName: user.firstName || 'there',
orderNumber,
items,
total,
}),
});
});

Previewing templates

Templates appear in the Email > Templates tab in the Builder UI (preview environment only). The preview function returns mock data that is passed to render to generate a live preview without sending any email.

Top

sendEmail options

Option

Type

Required

Description

to

string or string[]

Yes*

Recipient email address(es)

subject

string

Yes

Email subject line

body

string

Yes

Email body content (HTML string)

contentType

'text/plain' or 'text/html'

No

Content type. Defaults to text/plain

cc

string or string[]

No

CC recipient(s)

bcc

string or string[]

No

BCC recipient(s)

replyTo

string or string[]

No

Reply-to address(es)

*At least one of to, cc, or bcc is required.

The from address is set automatically and cannot be changed.

Error handling

sendEmail throws on failure:

  • EmailValidationError — invalid or missing parameters

  • EmailRateLimitError — rate limit exceeded (has retryAfter property in seconds)

import {
server,
sendEmail,
EmailRateLimitError,
captureError,
} from '@aha-app/builder-core';
import { renderOrderConfirmation } from '../emails/orderConfirmation';

server.data('confirmOrder', async ({ orderNumber, items, total }) => {
try {
sendEmail({
to: 'customer@example.com',
subject: `Order #${orderNumber} confirmed`,
contentType: 'text/html',
body: renderOrderConfirmation({
customerName: 'Jane',
orderNumber,
items,
total,
}),
});
} catch (e) {
if (e instanceof EmailRateLimitError) {
console.log(`Rate limited. Retry after ${e.retryAfter} seconds.`);
}
captureError(e as Error);
}
});

Rate limits

Emails are rate limited per application:

Period

Limit

Per minute

10

Per hour

30

Per day

100

HTML email guidelines

  • Use <table> for layout — <div> and flexbox/grid are unreliable across email clients

  • Inline all styles — <style> blocks are stripped by some clients (Gmail, Outlook)

  • Use font-family: Arial, sans-serif or other web-safe fonts

  • Set cellpadding="0" cellspacing="0" on all tables

  • Use a wrapper table at width="100%" with a centered inner table at a fixed width (e.g. 600px)

  • Buttons: use a <table> with background color wrapping an <a> tag — <button> elements are not supported

  • Avoid shorthand CSS (e.g. use padding-top, padding-right etc. or the padding shorthand with explicit values)

Top

Build it with Elle

Describe the capability you want in plain language, and Elle, the Aha! Builder AI assistant, builds it into your application using the framework above.

New to this? Open Elle, ask what it can do, and try a capability in your application today.

Top

Feedback received!

Error submitting feedback, please try again later