Documentation Index
Fetch the complete documentation index at: https://docs.launchmystore.io/llms.txt
Use this file to discover all available pages before exploring further.
Email Template Extensions
Email template extensions let your app ship branded HTML for the
transactional emails CustomerLMS sends on the merchant’s behalf — order
confirmations, shipment notifications, password resets, and more. The
template is stored in Postgres as part of the merchant’s store and is
selected at send time by event name.
This replaces the legacy hardcoded HTML in email.service.ts for any
event the app overrides. Merchants can also save their own templates
through the admin UI, with app-provided templates winning on conflict.
MVP status. Only the order_confirmation event currently checks the
DB for a template override. The other events listed below are still
served from the hardcoded fallback HTML — your registered template is
stored, but the email send path has not been refactored yet. New events
will start honoring the DB template as they are wired in.
How Email Templates Work
- Your app sends a template manifest at install time (or any time after,
via the app-scoped REST API).
- CustomerLMS upserts a row into the
EmailTemplates table, keyed by
(storeId, event, appId).
- When the merchant’s store fires the corresponding event (e.g. an order
is placed),
EmailTemplateService.getActiveTemplate(storeId, event)
selects the row to use.
- The chosen
htmlBody is rendered with the full Liquid engine
(interpolation, loops, conditionals, filters) and sent through the
merchant’s configured email provider — ZeptoMail by default, SMTP if
the merchant has set one. Custom templates only apply when the
merchant has configured their own SMTP, to protect the platform’s
shared sender reputation.
Architecture
Two separate channels connect your app to CustomerLMS — only one of them
carries email templates.
┌─────────────────────────────────────┐
│ Your app's backend │
│ (runs on your own server) │
└──────┬────────────────────────┬─────┘
│ │
┌────────────┘ └────────────┐
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────────────┐
│ iframe (browser) │ │ Server-to-server POST │
│ │ │ (no browser involved) │
│ Renders inside │ │ │
│ the merchant │ │ POST /api/v1/ │
│ dashboard │ │ email-templates. │
│ │ │ json │
│ Used for the │ │ │
│ app's admin UI │ │ Authorization: Bearer │
│ (settings page, │ │ <oauth-token> │
│ pickers, etc.) │ │ │
│ │ │ Body: { event, subject, │
│ NOT used for │ │ htmlBody, ... } │
│ template upload │ │ │
└─────────────────────┘ └────────┬─────────────────┘
│
▼
┌───────────────────────┐
│ CustomerLMS backend │
│ │
│ INSERT INTO │
│ EmailTemplates │
└───────────────────────┘
| Channel | Runs in | Used for |
|---|
| iframe (App Bridge) | Browser, embedded in merchant admin | App’s settings UI, resource pickers, toasts, modals — anything the merchant clicks |
| REST POST | Your backend server | Persisting templates into CustomerLMS |
The merchant never types HTML into a browser. Your server POSTs the
template directly. The iframe is optional UI for triggering that POST
(e.g. a “Re-sync templates” button).
Two ways the POST happens
At install — CustomerLMS reads the emailTemplates array from your
install manifest and upserts each entry. No separate auth needed because
the merchant just authorized the install.
Merchant clicks Install
│
▼
CustomerLMS ──► reads extensions.emailTemplates from manifest
│
▼
INSERT INTO EmailTemplates ...
Anytime after install — Your server uses the OAuth access token it
received during install to POST a new template:
Your backend ──► POST /api/v1/email-templates.json
Authorization: Bearer <token>
{ event, subject, htmlBody }
│
▼
CustomerLMS ──► UPSERT EmailTemplates
WHERE appId = '<your-app>'
The appId is read from the bearer token — apps can never write rows
under another app’s appId.
Storage
Templates live in two places. Only one is authoritative.
1. Postgres EmailTemplates table (canonical)
This row is what the send pipeline reads. Source of truth.
TABLE EmailTemplates (
emailTemplateId UUID PRIMARY KEY,
storeId UUID, -- which merchant
appId VARCHAR NULL, -- which app authored it (NULL = merchant-set)
event VARCHAR, -- 'order_confirmation', etc.
subject TEXT, -- 'Order {{ order.name }} confirmed'
htmlBody TEXT, -- the full Liquid template
enabled BOOLEAN,
createdAt TIMESTAMP,
updatedAt TIMESTAMP,
UNIQUE (storeId, event, appId)
)
The unique constraint on (storeId, event, appId) is what makes
one merchant + N apps + N events safely concurrent:
storeId | event | appId | row
-----------+--------------------+-------------------+--------------
raja33... | order_confirmation | branded-emails | row A
raja33... | order_confirmation | transactional-pro | row B
raja33... | order_confirmation | NULL (merchant) | row C
raja33... | shipment_notif… | branded-emails | row D
Each row is independent; the resolution rules below pick one.
2. Disk audit copy (debug only)
When the install endpoint upserts a row, it also writes the same JSON
to disk under CustomerLMS:
r:/CustomerLMS/public/extensions/
└── <domainSlug>/ ← which merchant
└── <appHandle>/ ← which app
├── blocks/ (storefront blocks, if any)
├── snippets/ (Liquid snippets, if any)
├── assets/ (CSS/JS, if any)
└── email-templates/ ← here
├── order_confirmation.json
└── shipment_notification.json
Each file is a verbatim copy of the manifest entry:
{
"event": "order_confirmation",
"subject": "Order {{ order.name }} confirmed — thanks {{ customer.first_name }}!",
"htmlBody": "<!DOCTYPE html>...",
"enabled": true
}
The on-disk copy is never read at send time. It exists for developer
inspection (“what did I actually deploy?”), debugging (“the email looks
wrong — let me diff the on-disk copy against the DB”), and future
tooling (CLI pull/sync). If you edit the file directly, nothing happens
until you re-POST through the API.
End-to-end write path
The canonical Postgres row and the disk audit copy are written through
two independent paths — they are not chained from a single frontend
call. Picture them in parallel, both fed by the same install manifest:
Your app's backend
│
│ POST /api/v1/oauth/authorize ──► /apps install pipeline (BackendNest)
│ │
│ │ Reads manifest.extensions.emailTemplates
│ ▼
│ ┌─────────────────────┐
│ │ EmailTemplateSvc │
│ │ .upsert(...) │
│ │ │ │
│ │ ▼ │
│ │ PG EmailTemplates │ ◄── source of truth
│ │ (UPSERT row) │ (read at send time)
│ └─────────────────────┘
│
│ POST /api/apps/install-extensions (CustomerLMS Next.js)
▼
┌──────────────────────────────────┐
│ src/pages/api/apps/ │
│ install-extensions.js │
│ │
│ For each emailTemplate: │
│ Write disk audit copy: │
│ fs.writeFile( │ ◄── debug-only, never read at send time
│ public/extensions/ │
│ .../{event}.json ) │
└──────────────────────────────────┘
The frontend install-extensions.js handler only writes the on-disk
audit copy — it does not forward to the backend. The Postgres row is
upserted independently by BackendNest’s app-install pipeline when it
reads the install manifest.
End-to-end read path (at send time)
Customer places an order
│
▼
order.service.ts → emailService.orderCustomerEmail(data)
│
│ 1. Build smtpSettings from merchant preferences
│ (custom SMTP host/port/user/pass, or null)
│
│ 2. Check reputation gate (see below):
│ if (!hasCustomSmtp) → skip override, use default
│
│ 3. Query the active template:
│
▼
┌────────────────────────────────────────┐
│ SELECT * FROM EmailTemplates │
│ WHERE storeId = <store> │
│ AND event = 'order_confirmation' │
│ AND enabled = true │
│ ORDER BY appId DESC, createdAt DESC │
│ LIMIT 1 │
└────────────────────────────────────────┘
│
▼
Render htmlBody with LiquidJS + context
│
▼
Send via nodemailer (custom SMTP) or ZeptoMail
│
▼
Customer's inbox
SMTP Reputation Gate
Even when a template row exists, it only fires if the merchant has
configured their own SMTP. With the platform’s shared ZeptoMail, the
send falls back to the hardcoded default template.
const hasCustomSmtp = !!data.smtpSettings;
const dbTemplate =
data.storeId && hasCustomSmtp
? await emailTemplateService.getActiveTemplate(storeId, event)
: null;
Why this gate exists. Imagine 1,000 stores install the same buggy app.
Without the gate, every order email from those stores would go through
the platform’s shared ZeptoMail with malformed/spammy HTML — and
ZeptoMail’s spam filters would start marking noreply@launchmystore.io
as untrustworthy. Every store on the platform would lose
deliverability.
With the gate, a bad template only fires on stores that configured
their own SMTP. Damage is scoped to that one merchant’s
mail.yourstore.com reputation — not the platform’s.
| Merchant SMTP config | Custom template fires? | Sender reputation at risk |
|---|
| Shared ZeptoMail (default) | ❌ No — default HTML | Platform (protected) |
Custom SMTP (senderEmail = "custom") | ✅ Yes — DB template | Merchant’s own |
If an override exists but the gate blocks it, the backend logs a warning
so the merchant can debug why their template “isn’t working”:
[email] skipping order_confirmation template override for store <id>:
custom SMTP not configured. Falling back to default to protect ZeptoMail
sender reputation.
Supported Events
| Event | Status | Description |
|---|
order_confirmation | Wired (MVP) | Sent to the customer when an order is placed |
order_cancellation | Planned | Sent when an order is cancelled |
shipment_notification | Planned | Sent when a shipment goes out |
abandoned_cart | Planned | Sent to recover an abandoned checkout |
password_reset | Planned | Sent when a customer requests a password reset |
account_invitation | Planned | Sent when a merchant invites a customer to claim their account |
newsletter_welcome | Planned | Sent on newsletter signup |
Until an event is “wired,” registering a template for it is a no-op at
send time — the row is stored, but email.service.ts continues to use
the legacy hardcoded HTML. Track the changelog for events moving to
Wired status.
Template Variables
Templates are rendered with the full Liquid engine — {{ var.path }}
interpolation, {% for %} loops, {% if %} conditionals, {% assign %},
and built-in filters (upcase, date, truncate, plus, times,
money_without_currency, etc.) all work. Missing values render as
empty strings.
Both the subject and htmlBody fields are rendered with the same
engine and context.
Variables available for order_confirmation
Order — top-level fields about the order:
| Variable | Type | Description |
|---|
{{ order.id }} | string | Public order id (e.g. 1042) |
{{ order.invoice_id }} | string | Invoice id |
{{ order.name }} | string | Display name on the order |
{{ order.total }} | number | Order total (pre-adjustments) |
{{ order.final_price }} | number | Final price the customer paid |
{{ order.currency }} | string | ISO currency code (USD, INR, …) |
{{ order.payment_method }} | string | Payment method label |
{{ order.created_at }} | string | ISO timestamp; pipe through | date |
{{ order.url }} | string | Storefront base URL |
{{ order.address }} | string | Shipping street |
{{ order.address_line_2 }} | string | Shipping address line 2 |
{{ order.city }} | string | Shipping city |
{{ order.state }} | string | Shipping state |
{{ order.country }} | string | Shipping country |
{{ order.pin_code }} | string | Shipping postal code |
{{ order.line_items_count }} | number | Number of line items in the order |
order.line_items | array | Iterable line items — see below |
Line items — each item in order.line_items exposes:
| Variable | Type | Description |
|---|
{{ item.name }} | string | Product name |
{{ item.image }} | string | Product image URL (40×40 recommended) |
{{ item.quantity }} | number | Quantity ordered |
{{ item.unit_price }} | number | Per-unit price (uses discountPrice if non-zero, else salePrice) |
{{ item.line_total }} | number | unit_price × quantity |
Customer:
| Variable | Description |
|---|
{{ customer.first_name }} or {{ customer.firstName }} | First token of customer.name |
{{ customer.name }} | Full name |
{{ customer.email }} | Email address |
{{ customer.mobile }} | Phone number |
Shop:
| Variable | Description |
|---|
{{ shop.name }} | Store / business name |
{{ shop.url }} | Storefront URL |
{{ shop.currency }} | Default currency |
Helpers:
| Variable | Description |
|---|
{{ product_table }} | Pre-rendered HTML <tr> rows for line items — drop straight into a <tbody>. Use this if you don’t want to author your own rows. |
{{ currency_symbol }} | Currency symbol matching shop.currency ($, ₹, €, …) |
Iterating line items
When a customer orders multiple products, loop over order.line_items
to render one row each:
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#f9fafb;">
<th align="left">Item</th>
<th align="center">Qty</th>
<th align="right">Price</th>
<th align="right">Total</th>
</tr>
</thead>
<tbody>
{% for item in order.line_items %}
<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:12px;">
<img src="{{ item.image }}" width="40" height="40"
style="vertical-align:middle;border-radius:6px;" />
<span style="margin-left:8px;">{{ item.name }}</span>
</td>
<td align="center">{{ item.quantity }}</td>
<td align="right">{{ currency_symbol }}{{ item.unit_price }}</td>
<td align="right"><strong>{{ currency_symbol }}{{ item.line_total }}</strong></td>
</tr>
{% endfor %}
</tbody>
</table>
Given a 2-item order, this renders two <tr> rows. Inside the loop you
also have access to the standard Liquid forloop object:
{% for item in order.line_items %}
<tr style="background:{% cycle '#ffffff', '#f9fafb' %};">
<td>{{ forloop.index }}. {{ item.name | truncate: 40 }}</td>
<td>{{ item.quantity }} × {{ currency_symbol }}{{ item.unit_price }}</td>
<td>
{% if item.quantity > 1 %}
<strong>{{ currency_symbol }}{{ item.line_total }}</strong>
{% else %}
{{ currency_symbol }}{{ item.line_total }}
{% endif %}
</td>
</tr>
{% endfor %}
Useful built-in filters
| Filter | Example | Result |
|---|
upcase / downcase | {{ customer.first_name | upcase }} | JOHN |
truncate | {{ item.name | truncate: 20 }} | Strawberry Milk - Va... |
date | {{ order.created_at | date: "%b %d, %Y" }} | Apr 12, 2026 |
plus / minus / times / divided_by | {{ item.unit_price | times: item.quantity }} | Per-row subtotal |
round | {{ order.total | round: 2 }} | 42.99 |
default | {{ order.address_line_2 | default: "" }} | Empty if missing |
escape | {{ item.name | escape }} | HTML-safe text |
Template Manifest
Each entry under extensions.emailTemplates is one event override.
{
"handle": "branded-emails",
"name": "Branded Email Templates",
"version": "1.0.0",
"extensions": {
"emailTemplates": [
{
"event": "order_confirmation",
"subject": "Order {{ order.id }} confirmed — thanks {{ customer.firstName }}!",
"htmlBody": "<!DOCTYPE html><html><body style=\"font-family: Arial, sans-serif;\"><h1>Thank you, {{ customer.firstName }}</h1><p>Your order <strong>{{ order.id }}</strong> is in.</p>{{ product_table }}<p>Total: {{ currency_symbol }}{{ order.total }}</p><p>We will email you again when it ships.</p></body></html>",
"enabled": true
}
]
}
}
Fields
| Field | Required | Description |
|---|
event | yes | Event name (see Supported events). Lowercase, snake_case. |
htmlBody | yes | HTML template string. Run through the variable substitution before send. |
subject | no | Subject line template. Defaults to the legacy subject for the event. |
enabled | no | Disable the override without deleting it. Defaults to true. |
appId | auto | Set automatically for app-scoped writes. Merchant-set rows have appId = null. |
Resolution Rules
For each (storeId, event), the active template is selected as follows:
enabled = false rows are skipped entirely.
- App-provided rows win over merchant-set rows. Rationale: the
merchant explicitly installed the app that ships the template, so the
override is opt-in.
- Within the same app bucket, newest
createdAt wins. Developers
can iterate on their template without manual deletion.
If no row matches, CustomerLMS falls back to the legacy hardcoded HTML
for that event.
┌─────────────────────────────────────────────────────────────┐
│ Active template for (storeId=X, event=order_confirmation) │
├─────────────────────────────────────────────────────────────┤
│ enabled = true ✓ │
│ ↓ │
│ appId IS NOT NULL → highest priority │
│ ↓ │
│ createdAt DESC → newest first │
└─────────────────────────────────────────────────────────────┘
REST API
Two parallel controllers — pick the one that matches your auth context.
Merchant-scoped (Bearer JWT)
Used by the admin UI when a merchant edits a template directly. storeId
is resolved from the JWT.
| Method | Path | Description |
|---|
GET | /email-templates?event= | List templates for the store. Filter by event or appId. |
GET | /email-templates/:id | Read a single template. |
POST | /email-templates | Upsert keyed by (storeId, event, appId). |
PUT | /email-templates/:id | Update subject / htmlBody / enabled. |
DELETE | /email-templates/:id | Remove the row. |
App-scoped (OAuth Bearer)
Used by your app after install. The appId field is forced to the
authenticated app’s id — apps cannot impersonate each other.
Required scopes: read_email_templates, write_email_templates.
| Method | Path | Description |
|---|
GET | /api/v1/email-templates.json?event= | List the current app’s templates for the store. |
POST | /api/v1/email-templates.json | Upsert the current app’s template for an event. |
DELETE | /api/v1/email-templates/:id.json | Delete one of the current app’s templates. |
Upsert example
curl -X POST https://api.launchmystore.io/api/v1/email-templates.json \
-H "Authorization: Bearer <app-access-token>" \
-H "Content-Type: application/json" \
-d '{
"event": "order_confirmation",
"subject": "Order {{ order.id }} confirmed",
"htmlBody": "<!DOCTYPE html>...",
"enabled": true
}'
Response:
{
"status": 201,
"message": "success",
"template": {
"id": "et_8a7f...",
"store_id": "store_...",
"app_id": "app_...",
"event": "order_confirmation",
"subject": "Order {{ order.id }} confirmed",
"html_body": "<!DOCTYPE html>...",
"enabled": true,
"created_at": "...",
"updated_at": "..."
}
}
Installing at App Install
The recommended flow is to ship your templates inline with the rest of
your extension files during install. Add an emailTemplates array to
the extensions payload of POST /api/apps/install-extensions:
curl -X POST https://store.launchmystore.io/api/apps/install-extensions \
-H "Content-Type: application/json" \
-d '{
"domainSlug": "acme-store",
"appHandle": "branded-emails",
"extensions": {
"emailTemplates": [
{
"event": "order_confirmation",
"subject": "Order {{ order.id }} confirmed",
"htmlBody": "<!DOCTYPE html>...",
"enabled": true
}
]
}
}'
This single call writes both the canonical Postgres row and the disk
audit copy — see Storage for the full layout.
Complete Example: Order Confirmation Template
A branded order confirmation that uses all of the MVP-supported variables.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
max-width: 600px;
margin: 0 auto;
color: #1a1a1a;
}
.header { padding: 24px; background: #f7f7f7; }
.body { padding: 24px; }
.totals { border-top: 1px solid #e1e3e5; padding-top: 16px; }
.footer { padding: 24px; color: #6b7177; font-size: 12px; }
</style>
</head>
<body>
<div class="header">
<h1>{{ shop.name }}</h1>
</div>
<div class="body">
<h2>Thank you, {{ customer.firstName }}.</h2>
<p>
Your order <strong>{{ order.id }}</strong> was placed on
{{ order.created_at }}.
</p>
{{ product_table }}
<div class="totals">
<p><strong>Total:</strong> {{ currency_symbol }}{{ order.total }}</p>
<p><strong>Paid with:</strong> {{ order.payment_method }}</p>
</div>
<h3>Shipping to</h3>
<address>
{{ customer.name }}<br />
{{ order.address }}<br />
{{ order.city }}, {{ order.state }} {{ order.pin_code }}<br />
{{ order.country }}
</address>
<p>
<a href="{{ order.url }}">View your order</a>
</p>
</div>
<div class="footer">
Sent by {{ shop.name }} · {{ shop.url }}
</div>
</body>
</html>
See Also
- App Listing — declaring scopes for
email-template access.
- Aqua Filters — full reference of the Liquid filters
available in both theme rendering and email templates.
- App Bridge Overview — the iframe channel for
merchant-facing admin UI, separate from this REST-based template flow.
- Webhooks — programmatic alternative
if you want to send email yourself instead of overriding the
built-in template.