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.
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 |
|
|
No |
Display name shown in the Builder UI (defaults to key) |
|
|
Yes |
Email subject line |
|
|
Yes |
Returns mock data for the Builder UI preview |
|
|
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.
sendEmail options
Option |
Type |
Required |
Description |
|
|
Yes* |
Recipient email address(es) |
|
|
Yes |
Email subject line |
|
|
Yes |
Email body content (HTML string) |
|
|
No |
Content type. Defaults to |
|
|
No |
CC recipient(s) |
|
|
No |
BCC recipient(s) |
|
|
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 parametersEmailRateLimitError— rate limit exceeded (hasretryAfterproperty 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 clientsInline all styles —
<style>blocks are stripped by some clients (Gmail, Outlook)Use
font-family: Arial, sans-serifor other web-safe fontsSet
cellpadding="0" cellspacing="0"on all tablesUse 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 supportedAvoid shorthand CSS (e.g. use
padding-top,padding-rightetc. or thepaddingshorthand with explicit values)
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.