Recipe: Send Transactional Email¶
Send emails from Centrali functions using your preferred email provider (SendGrid, Resend, AWS SES, Postmark, etc.).
Problem¶
Your app needs to send transactional emails — welcome messages, password resets, order confirmations, notifications. You want to trigger these from Centrali when data changes or events occur.
Solution¶
Create a function that calls your email provider's API, then wire it to an event-driven trigger so emails send automatically when records are created or updated.
Example: SendGrid¶
Step 1: Create the function¶
async function run() {
const apiKey = triggerParams.sendgridApiKey;
const fromEmail = triggerParams.fromEmail || 'noreply@yourapp.com';
const { to, subject, html, text } = executionParams.payload || {};
if (!to || !subject) {
return { success: false, error: 'Missing required fields: to, subject' };
}
try {
const response = await api.httpPost(
'https://api.sendgrid.com/v3/mail/send',
{
personalizations: [{ to: [{ email: to }] }],
from: { email: fromEmail },
subject,
content: [
{ type: 'text/html', value: html || text || '' },
],
},
{
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
}
);
api.log({ sent: true, to, subject });
return { success: true, data: { sent: true } };
} catch (error) {
api.log({ error: error.message, to });
return { success: false, error: error.message };
}
}
Step 2: Add domain and configure trigger¶
- Add
api.sendgrid.comto Logic → Domains - Create an endpoint trigger with static params:
Step 3: Call from your app¶
await centrali.triggers.invokeEndpoint('send-email', {
payload: {
to: 'user@example.com',
subject: 'Welcome to MyApp!',
html: '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
},
});
Example: Resend¶
async function run() {
const apiKey = triggerParams.resendApiKey;
const fromEmail = triggerParams.fromEmail || 'noreply@yourapp.com';
const { to, subject, html } = executionParams.payload || {};
const response = await api.httpPost(
'https://api.resend.com/emails',
{ from: fromEmail, to, subject, html },
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
return { success: true, data: { id: response.id } };
}
Add api.resend.com to allowed domains.
Example: AWS SES (Simple Email Service)¶
AWS SES uses the REST API with SigV4 signing, which is complex. The simplest approach is to use SES v2's simpler HTTP API with an IAM access key:
async function run() {
const { accessKeyId, secretAccessKey, region } = triggerParams;
const { to, subject, html } = executionParams.payload || {};
const fromEmail = triggerParams.fromEmail;
// SES v2 SendEmail via simple HTTPS endpoint
// Note: For production, consider using an intermediary API gateway
// or a service like Resend/SendGrid which wraps SES with a simpler API.
const response = await api.httpPost(
`https://email.${region}.amazonaws.com/v2/email/outbound-emails`,
{
FromEmailAddress: fromEmail,
Destination: { ToAddresses: [to] },
Content: {
Simple: {
Subject: { Data: subject },
Body: { Html: { Data: html } },
},
},
},
{
headers: {
'Content-Type': 'application/json',
'X-Amz-Access-Key': accessKeyId,
},
}
);
return { success: true, data: response };
}
Add email.{region}.amazonaws.com to allowed domains.
AWS SigV4 Signing
AWS APIs typically require SigV4 request signing, which is difficult to implement inside a compute function. For AWS SES, consider using a wrapper service like Resend (which uses SES under the hood) or placing an API Gateway in front of SES with IAM auth.
Event-driven: Send email on record creation¶
Wire the email function to fire automatically when a record is created:
- Create an event-driven trigger on the
record.createdevent for your collection - In the function, extract the record data from
executionParams:
async function run() {
const apiKey = triggerParams.sendgridApiKey;
const fromEmail = triggerParams.fromEmail;
// Event-driven triggers receive the record in executionParams
const record = executionParams.payload;
if (!record?.data?.email) {
api.log({ skipped: true, reason: 'No email field on record' });
return { success: true, data: { skipped: true } };
}
await api.httpPost(
'https://api.sendgrid.com/v3/mail/send',
{
personalizations: [{ to: [{ email: record.data.email }] }],
from: { email: fromEmail },
subject: `Welcome, ${record.data.name || 'there'}!`,
content: [{ type: 'text/html', value: '<h1>Welcome!</h1>' }],
},
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
return { success: true, data: { emailed: record.data.email } };
}
HTML Templates¶
The examples above hardcode the email HTML in the function code. That works for simple emails, but for richer templates you'll want to separate content from logic. There are three approaches — pick the one that fits your team.
Approach 1: Inline template helper¶
Build the HTML inside the function using a helper. Good for developer-managed templates where everything lives in code:
async function run() {
const apiKey = triggerParams.resendApiKey;
const fromEmail = triggerParams.fromEmail;
const { to, name, orderNumber, items, total } = executionParams.payload || {};
const html = buildOrderConfirmation({ name, orderNumber, items, total });
await api.httpPost(
'https://api.resend.com/emails',
{ from: fromEmail, to, subject: `Order #${orderNumber} confirmed`, html },
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
return { success: true, data: { sent: true } };
}
function buildOrderConfirmation({ name, orderNumber, items, total }) {
const itemRows = items
.map(item => `
<tr>
<td style="padding: 8px; border-bottom: 1px solid #eee;">${item.name}</td>
<td style="padding: 8px; border-bottom: 1px solid #eee; text-align: right;">$${item.price.toFixed(2)}</td>
</tr>
`)
.join('');
return `
<!DOCTYPE html>
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #333;">Order Confirmed</h1>
<p>Hi ${name || 'there'},</p>
<p>Your order <strong>#${orderNumber}</strong> has been confirmed.</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<thead>
<tr style="background: #f5f5f5;">
<th style="padding: 8px; text-align: left;">Item</th>
<th style="padding: 8px; text-align: right;">Price</th>
</tr>
</thead>
<tbody>
${itemRows}
<tr>
<td style="padding: 8px; font-weight: bold;">Total</td>
<td style="padding: 8px; text-align: right; font-weight: bold;">$${total.toFixed(2)}</td>
</tr>
</tbody>
</table>
<p style="color: #888; font-size: 12px;">Thank you for your purchase.</p>
</body>
</html>
`;
}
Approach 2: api.renderTemplate() with Handlebars¶
The compute runtime includes a built-in api.renderTemplate() method powered by Handlebars. This supports nested variables ({{order.total}}), conditionals ({{#if}}...{{/if}}), and loops ({{#each items}}). See the Handlebars templating in compute functions blog post for a full walkthrough.
async function run() {
const apiKey = triggerParams.resendApiKey;
const fromEmail = triggerParams.fromEmail;
const { to, name, orderNumber, items, total } = executionParams.payload || {};
// Define the template with Handlebars syntax
const subjectTemplate = 'Order #{{orderNumber}} Confirmed';
const htmlTemplate = `
<h1>Order Confirmed</h1>
<p>Hi {{name}},</p>
<p>Your order <strong>#{{orderNumber}}</strong> has been confirmed.</p>
<table style="width: 100%; border-collapse: collapse;">
{{#each items}}
<tr>
<td style="padding: 8px;">{{this.name}}</td>
<td style="padding: 8px; text-align: right;">\${{this.price}}</td>
</tr>
{{/each}}
</table>
<p><strong>Total: \${{total}}</strong></p>
`;
// Build the context object
const context = { name, orderNumber, items, total };
// Render using the built-in template engine
const subject = api.renderTemplate(subjectTemplate, context);
const html = api.renderTemplate(htmlTemplate, context);
await api.httpPost(
'https://api.resend.com/emails',
{ from: fromEmail, to, subject, html },
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
return { success: true, data: { sent: true } };
}
Approach 3: Templates stored in a Centrali collection (recommended)¶
For teams where non-developers need to edit email content without touching code, store templates as records in a collection. The function fetches the template at runtime and renders it with api.renderTemplate().
Step 1: Create an email-templates collection with these fields:
| Field | Type | Description |
|---|---|---|
eventType | text | Event identifier (e.g., order_confirmation, welcome, password_reset) |
name | text | Human-readable name for the Console UI |
subject | text | Subject line with {{variable}} placeholders |
html | text | Full HTML body with {{variable}} placeholders |
fromName | text | Sender display name (e.g., MyApp) |
fromEmail | text | Sender email (e.g., orders@myapp.com) |
isActive | boolean | Toggle to enable/disable without deleting |
Step 2: Create a template record in the Console:
{
"eventType": "order_confirmation",
"name": "Order Confirmation",
"subject": "Order #{{order.orderNumber}} confirmed",
"html": "<h1>Hi {{customer.name}},</h1><p>Your order <strong>#{{order.orderNumber}}</strong> for {{order.total}} has been confirmed.</p>{{#each items}}<p>{{this.name}} — {{this.price}}</p>{{/each}}<p>We'll notify you when it ships.</p>",
"fromName": "MyApp",
"fromEmail": "orders@myapp.com",
"isActive": true
}
Step 3: Function that fetches and renders:
async function run() {
const apiKey = triggerParams.resendApiKey;
const { to, eventType, context } = executionParams.payload || {};
if (!eventType) {
return { success: false, error: 'eventType is required' };
}
// Fetch the active template for this event
const templates = await api.queryRecords('email-templates', {
filter: {
'data.eventType': eventType,
'data.isActive': true,
},
pageSize: 1,
});
const items = templates?.items || templates?.data || [];
if (items.length === 0) {
api.log({ skipped: true, reason: `No active template for ${eventType}` });
return { success: true, data: { skipped: true, reason: `No active template for ${eventType}` } };
}
const template = items[0].data?.data || items[0].data;
// Render subject and HTML with the built-in template engine
const subject = api.renderTemplate(template.subject, context);
const html = api.renderTemplate(template.html, context);
// Send via email provider
const response = await api.httpPost(
'https://api.resend.com/emails',
{
from: `${template.fromName} <${template.fromEmail}>`,
to,
subject,
html,
},
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
api.log({ sent: true, to, eventType, resendId: response?.id });
return { success: true, data: { sent: true, eventType, resendId: response?.id } };
}
Step 4: Call from your app:
await centrali.triggers.invokeEndpoint('send-email', {
payload: {
to: 'customer@example.com',
eventType: 'order_confirmation',
context: {
customer: { name: 'Sarah' },
order: { orderNumber: '10042', total: '$89.99' },
items: [
{ name: 'Widget Pro', price: '$59.99' },
{ name: 'Widget Case', price: '$30.00' },
],
},
},
});
This approach gives non-technical team members full control over email content from the Console — they can edit copy, adjust layouts, toggle templates on/off, and add new event types without deploying code. The function just fetches the latest active template and renders it with api.renderTemplate().
Common gotchas¶
- Allowed domains: Add your email provider's API domain to the allowed list, or requests will be blocked.
- From address verification: Most providers require you to verify your sender domain or email address before sending. Do this in the provider's dashboard, not in Centrali.
- Rate limits: Email providers have sending limits. For bulk sends, use the provider's batch API or queue emails in a collection and process them with a scheduled trigger.
- HTML escaping: If you build HTML from user input, sanitize it to avoid injection. Don't trust
record.datavalues in raw HTML.