Email Module — Admin
Operator-configurable email: outbound provider (Resend/SMTP), store brand tokens for transactional emails, per-template content overrides, and the block-based campaign builder.
Operator-configurable email: the outbound provider (Resend or SMTP), store brand tokens rendered into transactional/auth emails, per-template content overrides, and the block-based campaign builder.
Source:
api-modules/email/src/controllers/*, settings registryapi-modules/settings/src/registry/{admin/email.ts,store/email.ts,store/branding.ts}.
Configuration (settings)
All configuration is stored in the settings registry and edited via the standard settings UI (PUT /admin/settings, PUT /store/settings). The live mailer reads provider + brand at send time — changes apply on the next send, no redeploy.
Provider — admin.email
| Key | Type | Notes |
|---|---|---|
provider | "smtp" | "resend" | Active provider (default smtp). |
from_email | string | Sender address (verified on the provider). Empty → MAIL_FROM env fallback. |
from_name | string | Sender display name (Name <email>). |
reply_to | string | Optional Reply-To. |
resend_api_key | string (secret) | Shown when provider = resend. |
smtp_host / smtp_port / smtp_secure / smtp_user / smtp_password | — | Shown when provider = smtp. smtp_password is secret. |
When the provider is unconfigured (blank host / key), the mailer falls back to the SMTP_* env vars, so local dev and fresh installs keep working. Both providers are backed by @opencoredev/email-sdk (its resend and smtp adapters).
Brand tokens — store.branding + store.email
Transactional and auth emails inherit these. Campaign emails do NOT — they are fully standalone.
| Key | Group | Notes |
|---|---|---|
accent_color, accent_text_color | store.branding | Hex; buttons/links. |
logo_url | store.email | Optional; falls back to store.branding.logo_url. |
footer_links | store.email | { label, url }[]. |
social_links | store.email | { platform, url, icon }[] — icon is an uploaded image (or pasted URL); renders as an icon in the footer. |
footer_text | store.email | Legal small print / address. |
The storefront URL used in emails is read from store.storefront_urls.store_url, falling back to store.seo.canonical_base_url when blank.
Auth email link paths — store.email
Auth emails link to the storefront (not the API). Each link is built as store.storefront_urls.store_url (falling back to store.seo.canonical_base_url) + the configured path + the better-auth token. Defaults match the store UI's routes; override per deployment.
| Key | Default | Token param |
|---|---|---|
auth_reset_password_path | /auth/reset-password | ?token= |
auth_verification_path | /auth/verification | ?code= |
auth_magic_link_path | /auth/magic-link | ?token= |
auth_change_email_path | /auth/change-email | ?token= |
auth_delete_account_path | /account-deleted | ?token= |
If no store base URL is set, the email keeps better-auth's own server URL.
Endpoints
Provider test
POST /admin/email/test — send a test email to verify the active provider config. Permission: emailTemplate:test.
// body
{ "to": "me@example.com" }Transactional template overrides
Every template — notification templates like order.placed, the guest-checkout order confirmation guest_order.confirmation (the email a guest gets with their tokenized tracking link), the vendor-application lifecycle emails (vendor.application.submitted, vendor.application.approved, vendor.application.rejected, role vendor_member), the back-in-stock emails (back_in_stock.available to the customer — the only channel that can reach a guest subscriber — and back_in_stock.vendor_alert to vendor_member, the operator-triggered restock nudge), and the auth/default emails (auth.reset_password, auth.magic_link, auth.verification, auth.change_email, auth.delete_account, auth.password_changed, auth.welcome) — is data-driven: it has an editable default subject / heading / body (HTML with {{variables}}). The editor shows the real defaults (prefilled, not blank); you edit the body directly. At send, variables are interpolated and the body is rendered inside the brand shell (logo header + footer). The legacy React templates remain only as a fallback for any template without a registered default. Auth emails always send (the enabled toggle is ignored for them — you can't disable e.g. a password reset).
Permission: emailTemplate:view (read), emailTemplate:update (write), emailTemplate:test.
| Method | Path | Description |
|---|---|---|
GET | /admin/email/templates | List every registered template with its effective (override ?? default) subject/heading/body, enabled, hasOverride, available {{variables}}. |
GET | /admin/email/templates/:eventType/:role | One template's effective content + variable hints. |
PUT | /admin/email/templates/:eventType/:role | Upsert the override. Empty fields fall back to the code default. enabled:false skips that email (ignored for auth.*). |
POST | /admin/email/templates/:eventType/:role/preview | Render the current (unsaved) draft {subject, heading, body} to branded HTML with sample data; returns { subject, html }. Powers the editor's live preview. |
POST | /admin/email/templates/:eventType/:role/test | Render this template with sample data + current brand and send it to an address. |
Body/subject/heading support {{variable}} placeholders. Common variables (customer_name, store_name, store_url, support_email, accent_color) are always available; every top-level scalar field of the event payload is exposed in snake_case (money fields formatted, e.g. {{grand_total}} → ₹1250.00, with the raw value under {{grand_total_raw}}). Auth emails expose {{url}} (and {{new_email}} for email-change).
// PUT body
{
"enabled": true,
"subject": "Order {{order_number}} confirmed",
"heading": "Thanks, {{customer_name}}!",
"body": "<p>Hi {{customer_name}}, your order is on its way.</p><p>Total: {{grand_total}}.</p>"
}Campaigns (block builder)
Campaigns ride the existing broadcast engine (audience resolution, worker fan-out, scheduling, status counters — see notifications.md). The body is built from safe blocks and rendered standalone (no transactional brand chrome).
Permissions: campaignTemplate:view (read), campaignTemplate:manage (write), notifications:broadcast (send).
| Method | Path | Description |
|---|---|---|
GET | /admin/campaigns/templates | List saved campaign templates (paginated). |
GET | /admin/campaigns/templates/:id | One template. |
POST | /admin/campaigns/templates | Create a template. |
PUT | /admin/campaigns/templates/:id | Update a template. |
POST | /admin/campaigns/templates/:id/duplicate | Duplicate. |
DELETE | /admin/campaigns/templates/:id | Delete. |
POST | /admin/campaigns/preview | Render blocks → HTML (sample vars + store logo) for live preview. |
POST | /admin/campaigns/send | Send (or schedule) a campaign to an audience. |
Blocks: heading, text, image, logo, button, social, divider, spacer. Text/heading/button fields support {{variables}}. There is no raw-HTML block.
// POST /admin/campaigns/send
{
"audience": { "type": "all_customers" }, // or { "type": "user_ids", "userIds": [...] }
"subject": "Summer sale is live",
"previewText": "Up to 40% off",
"templateId": "…", // OR provide "blocks": [...] inline
"scheduleFor": "2026-07-01T09:00:00Z" // optional; send-now if omitted
}A templateId snapshots the template's subject + blocks at send time, so later edits never mutate an in-flight campaign.
Dynamic Product Listing Module — Admin
HTTP surface for managing dynamic product listing groups (named containers) and dynamic product listings (saved filter specs). Groups and listings are addressable by slug; the storefront…
Frequently Bought Together Module — Admin
HTTP surface for operating the Frequently-Bought-Together (FBT) recommendation pipeline — an offline batch that mines product co-purchase pairs from confirmed orders and stores a…