Email Channel
Norq compiles email templates from Markdown to responsive HTML email via MJML (a responsive email markup language). The output is Outlook-safe, mobile-responsive, and includes a plain-text fallback.
Pipeline: Markdown -> AST (parse tree) -> MJML -> HTML + plain text
Template file
email.md – always Markdown mode. Native JSON is not supported for email (HTML rendering is the core reason Norq exists).
Frontmatter
Frontmatter is YAML metadata at the top of the template, enclosed in --- markers.
---
subject: "Order #{{order.id}} has shipped!"
preheader: "Your order is on its way"
enabled: true
---| Field | Required | Description |
|---|---|---|
subject |
Yes | Email subject line. Supports {{expressions}}. |
preheader |
No | Preview text shown in inbox clients (Gmail, Outlook). |
enabled |
No | true (default) or false. Disables the channel. |
dir |
No | Text direction: ltr (default) or rtl. Sets dir on MJML body. |
lang |
No | Language code (e.g., ar, he, ur). Sets lang on the <html> tag. |
headers |
No | Custom email headers passed through to the provider. See Custom headers. |
Custom headers
Declare additional email headers with a headers: mapping in the frontmatter. Values support template interpolation, so per-recipient headers like X-Order-ID work the same way as the body.
---
subject: "Order #{{order.id}} has shipped!"
headers:
X-Notification-Type: "order-shipped"
X-Order-ID: "{{order.id}}"
---Headers are passed through to providers that support them (currently Resend and SendGrid) — they appear in the request body’s headers map. Standard headers like From, To, or Subject are not validated by Norq; the provider rejects them and the rejection surfaces via the HTTP response. Providers that don’t support custom headers (Console, custom HTTP adapters by default) ignore them.
Attachments
Attach files to an email by passing attachments on the SDK send() call (CLI flag is deferred to a future release). Each entry is { filename, content, contentType?, contentId?, disposition? }. The SDK reads the bytes from your buffer/byte-array and base64-encodes them transparently.
// Node SDK
import { readFileSync } from "fs";
await client.send("transactional/invoice", {
to: { email: "alice@example.com" },
data: { invoice: { id: "INV-123" } },
attachments: [
{
filename: "invoice.pdf",
content: readFileSync("./invoices/INV-123.pdf"), // Buffer
// contentType inferred from .pdf → "application/pdf"
},
{
filename: "logo.png",
content: readFileSync("./assets/logo.png"),
contentId: "logo", // referenced as <img src="cid:logo"> in HTML
disposition: "inline",
},
],
});contentType inference
When contentType is omitted, Norq infers from the filename’s last extension (case-insensitive) against a curated whitelist:
| Group | Extensions |
|---|---|
| Documents | pdf, doc, docx, xls, xlsx, ppt, pptx, odt, rtf |
| Images | png, jpg, jpeg, gif, webp, svg |
| Text & data | txt, csv, html, htm, md, json, xml, yaml, yml |
| Archives & misc | zip, ics |
| Media | mp3, mp4 |
Any other extension (e.g. .parquet, .exe) requires an explicit contentType — sending without one produces a clear error before any HTTP request goes out.
Inline images
Set disposition: "inline" and supply a contentId to embed an image directly in the HTML body:
<!-- email.md -->
<img src="cid:logo" alt="Acme" width="120">attachments: [{
filename: "logo.png",
content: pngBuffer,
contentId: "logo",
disposition: "inline",
}]Email clients resolve cid:logo against the multipart message and render the image inline. Norq validates that disposition: "inline" always carries a contentId and rejects the send before HTTP if the pair is incomplete.
Provider limits
Both providers enforce per-send total-size caps, validated locally before the HTTP request so you get a clear error instead of a network round-trip ending in 413:
| Provider | Cap | Body field |
|---|---|---|
| Resend | 40 MB total per send | attachments[].content (base64), content_type, optional content_id, disposition only when inline |
| SendGrid | 30 MB total per send | attachments[].content (base64), type (not content_type), disposition always emitted, content_id when inline |
The SDK normalises the same input into each provider’s wire shape — your application code is provider-agnostic.
Out of scope
These are deliberately deferred to later phases:
- CLI
--attachmentsflag — would need a JSON manifest pointing to disk paths; SDK users unaffected. - File-path option (
contentPath: "./file.pdf") — caller does file IO themselves; keeps the SDK out of relative-path security questions. cid:mismatch lint rule — warning when HTML references acid:that no attachment supplies. Tracked separately.- URL-only attachments (
{ url: "..." }— provider downloads) — bytes path is more universal; URL-only is a follow-up. - SuprSend attachments — provider implementation will land alongside other SuprSend-specific feature work.
Directive compilation
| Directive | Email output |
|---|---|
:::header |
Background-colored mj-section with centered text |
:::footer |
mj-section with small font (12px, #666666) |
:::action |
mj-button elements with href and styling |
:::callout |
mj-section with background color (#fff3cd) |
:::hero |
mj-image (full-width) |
:::fields |
HTML table with bold keys |
:::media |
mj-image for images; <a> link for video/file |
:::columns |
mj-section > mj-column (native multi-column) |
:::section |
mj-section > mj-column (single-column shorthand for :::columns + :::col) |
:::list |
HTML <ul> or <ol> inside mj-text |
:::highlight |
Dark card – mj-column with brand-color background, white text, 600 weight, 8px radius |
:::centered |
mj-text with align="center" |
:::social |
Social icon links – mj-social with auto-detected platform icons |
:::raw |
mj-raw passthrough – content must be a fenced code block with HTML |
Components prefixed with mj- are MJML tags that compile to responsive HTML table layouts.
Example
---
subject: "Order #{{order.id}} has shipped!"
preheader: "Your order is on its way"
---
::: header
Order Shipped
:::
Hey {{user.first_name}},
Your order **#{{order.id}}** has shipped and is on its way!
::: fields
Tracking ID: {{order.tracking_id}}
Estimated delivery: {{order.delivery_date}}
:::
::: action
[Track Order]({{tracking_url}}){primary}
:::
::: if order.gift_message
> {{order.gift_message}}
:::
::: footer
[Unsubscribe]({{unsubscribe_url}})
:::Highlight
Dark card with the brand color as background and white text. Use for announcements or special offers.
::: highlight
Limited-time offer: 50% off all plans!
:::Compiles to an mj-column with background-color set to the brand color (tokens.colors.brand, default #18181b), color: #ffffff, font-weight: 600, and border-radius: 8px. Override colors with attributes: ::: highlight {bg="#1e40af" color="#f0f0f0"}.
Centered
Center-aligned text block.
::: centered
Thank you for being a customer.
:::Compiles to mj-text with align="center". Override text color with attributes: ::: centered {color="#666666"}.
Compiled output
Norq produces three fields:
subject: The rendered subject line (expressions resolved)html: Responsive HTML email (MJML compiled)text: Plain-text fallback (auto-generated from Markdown)
Inline HTML
Raw HTML tags in template content are passed through to the compiled output. Use this for fine-grained styling that Markdown cannot express:
This text has <span style="color:red">red emphasis</span> and <u>underlined words</u>.The HTML is inserted directly into the MJML <mj-text> output. Standard email-safe tags work: <span>, <u>, <sub>, <sup>, <br>, <small>. Avoid block-level tags (<div>, <table>) inside paragraphs – use :::raw for complex HTML structures instead.
Auto-escaping
All {{expressions}} in email templates are HTML-escaped: <, >, &, " become their HTML entity equivalents. Use {{{triple-curly}}} to output raw HTML (the linter warns about this).
Inline formatting
Standard Markdown inline formatting maps to HTML:
| Markdown | HTML |
|---|---|
**bold** |
<strong>bold</strong> |
*italic* |
<em>italic</em> |
~~strikethrough~~ |
<s>strikethrough</s> |
`code` |
<code>code</code> |
[link](url) |
<a href="url">link</a> |
 |
<img src="src" alt="alt"> (via mj-image) |
Directive parameters
All content directives accept optional {key="value"} attributes on the opening line (email only; other channels ignore them). Place them inside {...} after the directive name:
::: hero {url="https://example.com/banner.png" align="center"}
:::
::: fields {padding="compact"}
Name: {{user.name}}
:::
::: highlight {bg="#1e40af" color="#f0f0f0"}
Special offer just for you.
:::
::: centered {color="#666666"}
Thank you for being a customer.
:::Attributes can be combined freely: ::: highlight {bg="#1e40af" align="center" padding="spacious"}.
Universal attributes
All directives support:
| Attribute | Values |
|---|---|
align |
left, center, right |
bg |
brand, card, highlight, #hex |
color |
brand, muted, danger, success, warning, #hex |
padding |
none, compact, normal, spacious, or numeric ("16", "16 32" for 16px 32px) |
size |
xs(12px), sm(14px), md(16px), lg(18px), xl(20px), 2xl(24px), 3xl(30px), 4xl(36px), 5xl(48px), 6xl(60px), 7xl(72px), 8xl(96px), or raw px (44, 44px) |
weight |
light, normal, medium, semibold, bold |
spacing |
tight(1.5), normal(1.6), relaxed(1.8), loose(2.0) |
tracking |
tighter(-0.05em), tight(-0.025em), normal(0), wide(0.025em), wider(0.05em), or raw em/px/rem (-1.4px, -0.03em) — emits CSS letter-spacing |
Expression interpolation in directive attributes
Attribute values support {{expression}} interpolation:
::: hero {url="{{hero_image_url}}"}
:::
::: callout {bg="{{warning_color}}"}
Check your account settings.
:::Or reference a brand token by name:
::: callout {bg="warning"}
Check your account settings.
:::This allows dynamic values driven by template data, or stable references to brand.tokens.colors.*.
Table directive
:::table renders an iterable array as a structured table. Rows are templated with a row variable:
::: table order.items
| Item | Qty | Price |
|------|-----|-------|
| {{row.name}} | {{row.qty}} | {{row.price}} |
:::The directive iterates over the array at the given path and renders each row using the inner template. Compiles to an HTML table inside mj-text.
Variable pipe arguments
Pipes can accept variable references as arguments (in addition to literal values):
Total: {{item.cost | multiply item.qty}}This multiplies item.cost by item.qty at render time. The argument is resolved as a variable path from the template data, not a string literal.
Pipes in frontmatter subjects
The subject frontmatter field supports the full expression syntax including pipes:
---
subject: "Your order of {{item.count | pluralize "item" "items"}} is ready"
---All pipes available in the template body are also available in frontmatter string fields.
RTL support
Set dir: rtl and lang in frontmatter for right-to-left languages (Arabic, Hebrew, Urdu, etc.):
---
subject: "{{subject_line}}"
dir: rtl
lang: ar
---The compiler sets dir="rtl" on the MJML body and adds lang to the <html> tag. All text alignment defaults flip (left becomes right). Explicit alignment params still override.
Custom web fonts
Declare web fonts in norq.config.yaml under fonts:
fonts:
- family: "Inter"
weight: 400
style: normal
src:
- url: "https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hiA.woff2"
format: woff2
- family: "Inter"
weight: 700
style: normal
src:
- url: "https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuFuYAZ9hiA.woff2"
format: woff2The compiler generates CSS @font-face rules from the font config. Fields: family (required), weight (default 400), style (default "normal"), src (list of {url, format} objects). Reference font names from brand.tokens.typography.*.fontFamily (fontFamily: "Inter, sans-serif"). Falls back to system fonts in clients that block web fonts.
Dark mode
Set modes.colorMode: auto in brand.yaml to generate @media (prefers-color-scheme: dark) CSS:
# brand.yaml
modes:
colorMode: auto
dark:
colors:
background: "#1a1a1a"
body: "#e5e5e5"
card: "#262626"When colorMode: auto, the compiler emits a <mj-style> block with dark-mode overrides drawn from modes.dark.colors. Clients that support prefers-color-scheme (Apple Mail, Outlook.com, Gmail app) switch automatically; others render the light palette.
Limitation: Only the email channel honours dark-mode overrides. Other channels ignore them.
Per-template override (force a specific mode regardless of recipient preference):
---
brand:
modes:
colorMode: dark # always render dark for this template
---Code syntax highlighting
Set codeTheme in brand.yaml (under tokens:) to enable syntax highlighting in fenced code blocks:
# brand.yaml
tokens:
codeTheme: "base16-ocean.dark"Fenced code blocks with a language tag get syntax-highlighted inline styles:
```json
{ "status": "shipped" }
```Highlighting uses the syntect library (TextMate grammar). Available syntax-highlighting themes: base16-ocean.dark, base16-ocean.light, InspiredGitHub, Solarized (dark), Solarized (light). Without codeTheme, code blocks render with a monospace font and neutral background.
Column width params
:::col uses the unified {key="value"} syntax for layout control:
::: columns
::: col {width="2/3"}
Main content area with more space.
:::
::: col {width="1/3" valign="top" bg="#f4f4f5"}
Sidebar with top-aligned text.
:::
:::| Attribute | Values | Effect |
|---|---|---|
width |
"1/2", "1/3", "2/3", "1/4", "3/4", "200px", "50%" |
Column width as fraction, pixels, or percentage |
valign |
"top", "middle", "bottom" |
Vertical alignment within the column |
bg |
"#hex" |
Column background color |
bg-image |
URL string | Background image for the column |
bg-size |
CSS value (default "cover") |
CSS background-size |
bg-repeat |
CSS value (default "no-repeat") |
CSS background-repeat |
bg-position |
CSS value (default "center center") |
CSS background-position |
Width fractions map to MJML percentage widths (e.g., 2/3 becomes width="66.66%"). Pixel values (e.g., "200px") and percentages (e.g., "50%") are passed through directly. Without width, columns split equally.
Note: bg-image on :::col generates correct MJML (background-url on <mj-column>), but mrml does not yet render background-url on <mj-column> to HTML. The background will not appear in the final email until mrml adds support. Use bg-image on :::columns (the section level) for a background image that renders today.
Single-column sections
:::section is shorthand for :::columns + :::col with a single column. Use it when you need section-level attributes without multi-column layout:
::: section {bg="#f4f4f5" padding="spacious"}
Content in a styled single-column section.
:::Accepts all :::columns attributes: bg, padding, flush, mobile, bg-image, etc.
Flush sections
Add {flush} to :::columns or :::section to fuse consecutive sections into a single <mj-wrapper>, eliminating vertical gaps:
::: section {flush bg="#1e3a5f"}

:::
::: section {flush}
Content directly below the banner with no gap.
:::The wrapper background color inherits from the first flush section’s bg. Inside flush sections, padding is forced to 0 and images default to padding="0" (compiler inference).
Mobile inline
By default, :::columns stacks vertically on mobile. Add {mobile="inline"} to keep columns side-by-side on mobile by wrapping them in <mj-group>:
::: columns {mobile="inline"}
::: col {width="1/4"}

:::
::: col {width="3/4"}
Text stays beside the icon on mobile.
:::
:::Component defaults and named styles
Defaults
Set per-component attribute defaults in brand.yaml under defaults::
# brand.yaml
defaults:
image:
padding: "0"
button:
borderRadius: "20"Defaults apply to all instances of that element type unless overridden. See Brand for details.
Named styles
Define reusable attribute bundles under styles: in brand.yaml and apply with {.name} syntax:
# brand.yaml
styles:
headline:
size: "3xl"
weight: "bold"
color: "brand"# Welcome aboard
{.headline}Multiple styles combine: {.headline .muted}. See Brand for details.
Attribute cascade
Attributes resolve in this priority order (highest wins):
- Inline attrs –
{size="lg"}on the element - Named styles –
{.headline}frombrand.yamlstyles: - Component defaults –
brand.yamldefaults.image - Compiler inference – dark bg text color, flush padding
- Brand tokens –
brand.tokens.colors.brand, typography, etc. - Built-in – Norq’s default values
Compiler inference
The email compiler applies smart defaults based on context:
- Dark background text color: When a
:::columnsor:::sectionhas a darkbgcolor (luminance < 0.3), text elements without an explicitcolorattribute automatically getcolor="#ffffff". - Flush padding propagation: Inside
{flush}sections, images default topadding="0"instead of the normal"10px 25px".
These inferred values sit below inline attrs and named styles in the cascade, so you can always override them.
Block-level attributes
Append {key="value"} on the line after a block to set rendering attributes:
::: callout
Important notice for all users.
:::
{size="lg" align="center"}
> A blockquote with custom styling.
{size="sm" color="secondary-text"}
# Big heading
{size="4xl" weight="bold"}Supported attributes:
| Attribute | Values |
|---|---|
size |
xs(12px), sm(14px), md(16px), lg(18px), xl(20px), 2xl(24px), 3xl(30px), 4xl(36px), 5xl(48px), 6xl(60px), 7xl(72px), 8xl(96px), or raw px (44, 44px) |
color |
brand, muted, danger, success, warning, #hex |
bg |
brand, card, highlight, #hex |
align |
left, center, right |
weight |
light, normal, medium, semibold, bold |
spacing |
tight(1.5), normal(1.6), relaxed(1.8), loose(2.0) |
tracking |
tighter(-0.05em), tight(-0.025em), normal(0), wide(0.025em), wider(0.05em), or raw em/px/rem |
padding |
none, compact, normal, spacious, or numeric ("16", "16 32" for 16px 32px) |
Block attributes are merged into the nearest preceding block’s MJML output. The merge_block_attrs function in the compiler handles this. Block attrs also work inside directives — place them after content within the directive body.
Buttons
Standalone buttons
Use {button} on a Markdown link to render it as a styled button outside of :::action directives:
[Get Started](https://example.com){button} Primary button
[Learn More](https://example.com){button.secondary} Secondary
[Confirm](https://example.com){button.success} Green
[Delete](https://example.com){button.danger} Red
[Caution](https://example.com){button.warning} Amber
[Custom](https://example.com){button color="#dc2626"} Custom color
[Full Width](https://example.com){button width="full"} Full width
[Rounded](https://example.com){button radius="20"} Custom border radius (px)Consecutive buttons on the same line render side-by-side:
[Instagram](https://insta.com){button.secondary} [TikTok](https://tiktok.com){button.secondary}Buttons inside :::action
Inside :::action, use {primary}, {secondary}, {danger} instead of {button}:
::: action
[Track Order]({{tracking_url}}){primary}
[Cancel Order]({{cancel_url}}){danger}
[View Details]({{details_url}}){secondary}
:::Multiple buttons on the same line render side-by-side.
Image attributes
Images support sizing, floating, and clickable wrappers via {key="value"} attributes:
{width="400"} Set width in pixels
{width="100%"} Full width
{float="left"} Float left
{float="right"} Float right
[](https://example.com) Clickable imageCombine attributes as needed:
{width="200" float="left"}Width values can be pixels ("400") or percentages ("100%"). Float wraps the image in an MJML column with the specified alignment.
Divider customization
Thematic breaks (***) compile to <mj-divider> and support {width, color, thickness} block attributes:
***
{width="50%" color="#000" thickness="2"}| Attribute | Values | Default |
|---|---|---|
width |
Percentage string ("50%", "100%") |
100% |
color |
#hex color |
#e5e5e5 |
thickness |
Pixel value ("1", "2") |
1 |
Emoji shortcodes
Emoji shortcodes are converted to Unicode emoji at compile time:
:rocket: :wave: :fire: :star:Norq supports 1800+ GitHub shortcodes (the full gemoji set). Unknown shortcodes pass through unchanged.
Note: In SMS, emoji characters trigger UCS-2 encoding, which limits each segment to 70 characters instead of 160. Use emoji sparingly in SMS templates.
Partials
Partials are reusable template fragments included with {{> partial-name}}. For email, partials can also be .mjml files containing channel-native MJML content. When both header.mjml and header.md exist in the partials directory, .mjml is preferred for email compilation. MJML partials support the full Handlebars syntax ({{#if}}, {{#each}}, etc.).
See Partials for directory layout and authoring details.
Best practices
- Keep subject lines under 60 characters for mobile display
- Use
preheaderto control the preview text (otherwise clients show the first body text) - Test with
norq previewto see the rendered email in a browser - Use
:::fieldsfor structured data instead of manual tables - Use
:::tablefor array data that needs tabular layout - Put legal/unsubscribe links in
:::footerfor consistent bottom placement