Norq

Whitelabeling

Multi-tenant SaaS products send notifications on behalf of their customers. Acme Corp and Beta Inc both use your help-desk product, but Acme’s password-reset email should look like Acme — purple brand, Inter font — and Beta’s should look like Beta — orange, Charter, different voice.

Norq supports this with a per-call brand override: pass brand: to send() (or compile()) and the same template renders with that tenant’s visual identity. One project, one set of templates, N brands at runtime.

const acmeBrand = await loadBrandFor(tenantId);   // from your tenant DB
 
await client.send("transactional/password-reset", {
  to:    { email: "alice@acme.com" },
  data:  { firstName: "Alice", resetUrl: "https://..." },
  brand: acmeBrand,                  // ← runtime override per recipient
});

How it works

brand is a partial brand — same shape as brand.yaml, just the bits you want to swap. Your project’s brand stays the base; the per-call brand merges on top using the same semantics as the per-template frontmatter brand: block.

Layer Source When
Built-in compiler defaults EmailTheme::default() Always (cheapest fallback)
Project brand.yaml Loaded once at new Norq(...) Cached on the client
Per-call brand: opt Passed to send() / compile() This send only
Template frontmatter brand: The template file itself Compile time, per template

Later layers win on per-field conflicts. Omitted fields inherit. There is no “reset to default” — pass an empty object to inherit everything from the project brand.

Shape of the override

The override accepts either a plain JS object or a YAML string. The object shape mirrors brand.yaml:

const acmeBrand = {
  meta: { name: "Acme" },
  tokens: {
    colors: {
      brand: "#6d28d9",
      "brand-text": "#ffffff",
    },
    typography: {
      body: { fontFamily: "Inter", fontSize: 16 },
    },
  },
  styles: {
    "button-primary": {
      bg: "{colors.brand}",
      color: "{colors.brand-text}",
    },
  },
  modes: {
    dark: {
      colors: { brand: "#a78bfa" },
    },
  },
};

YAML works identically:

const acmeBrandYaml = `
tokens:
  colors:
    brand: "#6d28d9"
modes:
  dark:
    colors:
      brand: "#a78bfa"
`;
 
await client.send("transactional/welcome", {
  to: { email: "alice@acme.com" },
  brand: acmeBrandYaml,
});

Pass YAML when your tenant DB stores brands as text; pass an object when you build them in-memory.

What’s overridable

Section Override path
Color tokens tokens.colors.<name> (or shorthand colors.<name>)
Typography body tokens.typography.body.fontFamily / .fontSize / .lineHeight
Code theme tokens.codeTheme (or shorthand codeTheme)
Dark mode strategy modes.colorMode
Dark color overrides modes.dark.colors.<name>
Default attrs defaults.<element>.<attr>
Styles styles.<bundle>.<attr>

The override surface mirrors the per-template frontmatter brand: block (see Brand → Per-template overrides).

What’s NOT overridable per send

  • element-typography — heading-tag → typography-token mapping is project-stable by design
  • fonts block — font delivery is a build-time concern (the email’s <head> carries the <link> tag); per-tenant font URLs need to live in brand.yaml
  • voice — per-tenant voice belongs in AGENTS_BRAND.md per project
  • meta — informational only

If a tenant truly needs a different font family, override tokens.typography.body.fontFamily with a CSS stack — that updates the inline styles in the rendered email even though the @font-face injection stays project-level.

SDK reference — all five languages

Every SDK exposes a brand parameter on send() (and the public-equivalent compile path). Supported in Node, Python, Go, Java, Ruby.

Node / TypeScript

import { Norq } from "norq.sh";
 
const client = new Norq({ projectDir: "./notifications" });
 
await client.send("transactional/welcome", {
  to: { email: "alice@acme.com" },
  data: { firstName: "Alice" },
  brand: {
    tokens: { colors: { brand: "#6d28d9" } },
    modes: { dark: { colors: { brand: "#a78bfa" } } },
  },
});

YAML string also accepted:

await client.send("transactional/welcome", {
  to: { email: "alice@acme.com" },
  brand: `
tokens:
  colors:
    brand: "#6d28d9"
`,
});

Python

from norq import Norq
 
client = Norq(project_dir="./notifications")
 
client.send(
    "transactional/welcome",
    to={"email": "alice@acme.com"},
    data={"firstName": "Alice"},
    brand={
        "tokens": {"colors": {"brand": "#6d28d9"}},
        "modes": {"dark": {"colors": {"brand": "#a78bfa"}}},
    },
)

Go

import "github.com/suprsend/norq/sdks/go"
 
client, _ := norq.NewClient(ctx, norq.WithProjectDir("./notifications"))
defer client.Close(ctx)
 
result, err := client.Send(ctx, "transactional/welcome", norq.SendOpts{
    To:   &norq.Recipient{Email: "alice@acme.com"},
    Data: map[string]any{"firstName": "Alice"},
    Brand: map[string]any{
        "tokens": map[string]any{
            "colors": map[string]any{"brand": "#6d28d9"},
        },
        "modes": map[string]any{
            "dark": map[string]any{
                "colors": map[string]any{"brand": "#a78bfa"},
            },
        },
    },
})

A YAML/JSON string also works (Brand: yamlString).

Java

import sh.norq.Norq;
import sh.norq.Types.SendOpts;
import sh.norq.Types.Recipient;
 
import java.util.Map;
 
Norq client = Norq.builder().projectDir("./notifications").build();
 
SendOpts opts = new SendOpts()
    .to(new Recipient().email("alice@acme.com"))
    .data(Map.of("firstName", "Alice"))
    .brand(Map.of(
        "tokens", Map.of("colors", Map.of("brand", "#6d28d9")),
        "modes",  Map.of("dark", Map.of(
            "colors", Map.of("brand", "#a78bfa")
        ))
    ));
 
client.send("transactional/welcome", opts);

Ruby

require "norq"
 
client = Norq::Client.new(project_dir: "./notifications")
 
client.send(
  "transactional/welcome",
  to: { email: "alice@acme.com" },
  data: { firstName: "Alice" },
  brand: {
    tokens: { colors: { brand: "#6d28d9" } },
    modes:  { dark: { colors: { brand: "#a78bfa" } } }
  }
)

Multi-tenant patterns

Pattern 1: Brand cached in your tenant DB

The most common shape. Each tenant row carries its brand as a JSON column or a YAML blob:

async function loadTenantBrand(tenantId: string): Promise<object> {
  const row = await db.query("SELECT brand FROM tenants WHERE id = $1", [
    tenantId,
  ]);
  return row.brand;        // already a JSON object
}
 
await client.send("transactional/password-reset", {
  to: { email: user.email },
  data: { firstName: user.firstName, resetUrl },
  brand: await loadTenantBrand(user.tenantId),
});

Pattern 2: One Norq client, hot brand swap

Single SDK instance, brand selected per request from the recipient context. Zero per-tenant memory cost — the SDK loads templates and the compiler once at construction.

const client = new Norq({ projectDir: "./notifications" });
 
app.post("/notify", async (req, res) => {
  await client.send(req.body.notificationId, {
    to: req.body.recipient,
    data: req.body.data,
    brand: req.user.tenant.brand,
  });
  res.sendStatus(202);
});

Pattern 3: Tenant brand from a registry

If you publish brands as registry packages (one per tenant), import once at startup and pick at send time:

import * as fs from "node:fs/promises";
 
const BRAND_CACHE = new Map<string, string>();
 
async function brandYamlFor(tenantId: string): Promise<string> {
  let yaml = BRAND_CACHE.get(tenantId);
  if (!yaml) {
    yaml = await fs.readFile(`./tenant-brands/${tenantId}/brand.yaml`, "utf8");
    BRAND_CACHE.set(tenantId, yaml);
  }
  return yaml;
}
 
await client.send(notifId, {
  to: recipient,
  brand: await brandYamlFor(tenantId),
});

Performance

The override path adds one extra in-memory merge per send() (or compile()) when brand is present. Calls without brand skip the helper entirely and reuse the project’s cached resolved brand — zero overhead for non-multi-tenant use.

The override cost itself is microseconds: a JSON deserialise + a small struct merge + a JSON serialise. The expensive bits (template parse, MJML render, partial expansion) are unaffected.

Authoring tenant brands

Brand authoring is the same as project brands — see the Brand page. The on-disk shape is brand.yaml, validated by schemas/norq-brand.schema.json.

A typical tenant brand only needs to override colors, dark colors, and maybe a font family:

# tenants/acme/brand.yaml
meta:
  name: Acme
 
tokens:
  colors:
    brand: "#6d28d9"
    background: "#ffffff"
  typography:
    body:
      fontFamily: "Inter, -apple-system, sans-serif"
 
modes:
  dark:
    colors:
      brand: "#a78bfa"

Run norq brand show against a tenant directory to verify the resolved tokens before storing the brand in your DB.

Validation and gotchas

Behaviour What you get
brand omitted Project’s default brand. Zero overhead.
brand: "" (empty string) Project’s default brand. Treated as “no override”.
brand: {} (empty object) Project’s default brand. Same as omitted.
brand: "tokens:\n bad: yaml" Throws NorqError: brand override failed: ... with the parser’s diagnostic.
Frontmatter brand: in the template Wins over the per-call brand for fields it specifies. Per-call brand fills the gaps.
modes.dark overrides Apply only to email; non-email channels ignore.

Architectural notes

Per-call brand override doesn’t reload the template, doesn’t re-parse the project, doesn’t touch the filesystem. It’s a single in-memory transform of the cached theme, applied right before the compile call. The compiler signature has always been compile(doc, data, theme) — multi-tenant just lets you build theme from per-call data.

The same code path is used by:

  • The frontmatter brand: block in templates (compile-time author override)
  • The --brand CLI flag for norq compile / norq send (developer flag for previewing brands)
  • The per-call brand: opt in the SDK (runtime tenant override)

All three share EmailTheme::merge_frontmatter in crates/core/src/types.rs.

See also