Skip to main content

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

  1. Your app sends a template manifest at install time (or any time after, via the app-scoped REST API).
  2. CustomerLMS upserts a row into the EmailTemplates table, keyed by (storeId, event, appId).
  3. When the merchant’s store fires the corresponding event (e.g. an order is placed), EmailTemplateService.getActiveTemplate(storeId, event) selects the row to use.
  4. 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     │
                                              └───────────────────────┘
ChannelRuns inUsed for
iframe (App Bridge)Browser, embedded in merchant adminApp’s settings UI, resource pickers, toasts, modals — anything the merchant clicks
REST POSTYour backend serverPersisting 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 configCustom template fires?Sender reputation at risk
Shared ZeptoMail (default)❌ No — default HTMLPlatform (protected)
Custom SMTP (senderEmail = "custom")✅ Yes — DB templateMerchant’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

EventStatusDescription
order_confirmationWired (MVP)Sent to the customer when an order is placed
order_cancellationPlannedSent when an order is cancelled
shipment_notificationPlannedSent when a shipment goes out
abandoned_cartPlannedSent to recover an abandoned checkout
password_resetPlannedSent when a customer requests a password reset
account_invitationPlannedSent when a merchant invites a customer to claim their account
newsletter_welcomePlannedSent 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:
VariableTypeDescription
{{ order.id }}stringPublic order id (e.g. 1042)
{{ order.invoice_id }}stringInvoice id
{{ order.name }}stringDisplay name on the order
{{ order.total }}numberOrder total (pre-adjustments)
{{ order.final_price }}numberFinal price the customer paid
{{ order.currency }}stringISO currency code (USD, INR, …)
{{ order.payment_method }}stringPayment method label
{{ order.created_at }}stringISO timestamp; pipe through | date
{{ order.url }}stringStorefront base URL
{{ order.address }}stringShipping street
{{ order.address_line_2 }}stringShipping address line 2
{{ order.city }}stringShipping city
{{ order.state }}stringShipping state
{{ order.country }}stringShipping country
{{ order.pin_code }}stringShipping postal code
{{ order.line_items_count }}numberNumber of line items in the order
order.line_itemsarrayIterable line items — see below
Line items — each item in order.line_items exposes:
VariableTypeDescription
{{ item.name }}stringProduct name
{{ item.image }}stringProduct image URL (40×40 recommended)
{{ item.quantity }}numberQuantity ordered
{{ item.unit_price }}numberPer-unit price (uses discountPrice if non-zero, else salePrice)
{{ item.line_total }}numberunit_price × quantity
Customer:
VariableDescription
{{ customer.first_name }} or {{ customer.firstName }}First token of customer.name
{{ customer.name }}Full name
{{ customer.email }}Email address
{{ customer.mobile }}Phone number
Shop:
VariableDescription
{{ shop.name }}Store / business name
{{ shop.url }}Storefront URL
{{ shop.currency }}Default currency
Helpers:
VariableDescription
{{ 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

FilterExampleResult
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

FieldRequiredDescription
eventyesEvent name (see Supported events). Lowercase, snake_case.
htmlBodyyesHTML template string. Run through the variable substitution before send.
subjectnoSubject line template. Defaults to the legacy subject for the event.
enablednoDisable the override without deleting it. Defaults to true.
appIdautoSet automatically for app-scoped writes. Merchant-set rows have appId = null.

Resolution Rules

For each (storeId, event), the active template is selected as follows:
  1. enabled = false rows are skipped entirely.
  2. 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.
  3. 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.
MethodPathDescription
GET/email-templates?event=List templates for the store. Filter by event or appId.
GET/email-templates/:idRead a single template.
POST/email-templatesUpsert keyed by (storeId, event, appId).
PUT/email-templates/:idUpdate subject / htmlBody / enabled.
DELETE/email-templates/:idRemove 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.
MethodPathDescription
GET/api/v1/email-templates.json?event=List the current app’s templates for the store.
POST/api/v1/email-templates.jsonUpsert the current app’s template for an event.
DELETE/api/v1/email-templates/:id.jsonDelete 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.