Skip to content

Feature: Expose Maily theme configuration for email steps #10095

@Swahjak

Description

@Swahjak

Feature: Expose Maily theme configuration for email steps

Description

The Maily rendering library (libs/maily-render) already has full theme support — a ThemeOptions interface, a setTheme() method, and 34+ internal references applying theme values during rendering. The render() function signature already accepts a theme parameter in its config object:

// libs/maily-render/src/render.ts
export async function render(content: JSONContent, config?: MailyConfig & RenderOptions): Promise<string> {
  const { theme, preview, ...rest } = config || {};
  maily.setTheme(theme || {});
  // ...
}

However, there is currently no way for users to actually configure or pass a theme. The API renderer hardcodes an empty theme (falling back to DEFAULT_THEME):

// apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts:474
const renderedMaily = await mailyRender(parsedMaily, { noHtmlWrappingTags });
// ^ theme is never passed

This means all emails render with hardcoded default colors and font sizes, with no ability to customize branding.

What's already done

Layer Status
libs/maily-renderThemeOptions type, setTheme(), deep merge with defaults Done
render() function accepts theme in config Done
Test coverage for custom themes (render.test.ts) Done

What's missing

Layer File(s) Work needed
Vendored renderer ThemeOptions libs/maily-render/src/maily.tsx Align with upstream RendererThemeOptions (see below)
Email control schema libs/application-generic/src/schemas/control/email-control.schema.ts Add optional theme field to emailControlZodSchema
Email UI schema Same file, emailUiSchema Decide on UI component for theme configuration
Control value sanitization libs/application-generic/src/utils/sanitize-control-values.ts Handle theme in sanitizeEmail()
API email renderer apps/api/.../email-output-renderer.usecase.ts Extract theme from control values and pass to mailyRender()
Dashboard editor apps/dashboard/src/components/maily/maily.tsx Expose theme configuration UI
Editor preview @novu/maily-core EditorProps Accept theme prop for real-time preview in the editor

Update vendored renderer to match upstream theme options

Novu's vendored ThemeOptions in libs/maily-render/src/maily.tsx is a reduced subset of what upstream Maily supports. The upstream RendererThemeOptions (from @maily-to/shared/src/theme.ts) includes several additional categories that the vendored version has stripped:

Current Novu ThemeOptions — only supports:

  • colors (13 semantic color properties)
  • fontSize (paragraph + footer font sizing)

Upstream RendererThemeOptions — additionally supports:

interface BaseThemeOptions {
  container?: Partial<{
    backgroundColor, maxWidth, minWidth,
    paddingTop, paddingRight, paddingBottom, paddingLeft,
    borderRadius, borderWidth, borderColor
  }>;
  body?: Partial<{
    backgroundColor,
    paddingTop, paddingRight, paddingBottom, paddingLeft
  }>;
  button?: Partial<{
    paddingTop, paddingRight, paddingBottom, paddingLeft,
    backgroundColor, color
  }>;
  link?: Partial<{ color }>;
  font?: {
    fontFamily: string;
    fallbackFontFamily: FallbackFont;
    webFont?: { url: string; format: FontFormat };
    fontStyle?: string;
    fontWeight?: string | number;
  } | null;
}

These upstream options enable essential branding capabilities:

  • Container: control email width, background color, padding, and border styling
  • Body: outer wrapper background and padding (important for "card-in-a-page" layouts)
  • Button: default CTA button colors and padding
  • Link: global link color
  • Font: custom font family with web font support (critical for brand consistency)

The vendored renderer should be updated to support the full upstream RendererThemeOptions interface, including container, body, button, link, and font customization.

Proposed scope levels

Theme could be configured at different scopes with inheritance:

  1. Environment-level (brand defaults) — applied to all emails in a Novu environment
  2. Layout-level — override environment defaults for emails using a specific layout
  3. Step-level — override for a single email step

This mirrors how layoutId currently works as an optional override per step.

At minimum, step-level support would already be valuable — it would allow passing theme through the existing control values mechanism.

Minimal implementation path

The smallest useful change would be:

  1. Update ThemeOptions in libs/maily-render/src/maily.tsx to match upstream RendererThemeOptions
  2. Add theme as an optional field to emailControlZodSchema (structure field, code-controlled)
  3. Pass theme from control values to mailyRender() in the API renderer
  4. Dashboard UI can come later — theme would initially be configured through code/API

This would let framework users set email themes programmatically:

workflow('welcome-email', async ({ step }) => {
  await step.email('send', async (controls) => ({
    subject: controls.subject,
    body: controls.body,
  }), {
    controlSchema: {
      // ...
      theme: {
        colors: { heading: '#1a1a2e', paragraph: '#16213e' },
        button: { backgroundColor: '#e94560', color: '#ffffff' },
        font: { fontFamily: 'Inter', fallbackFontFamily: 'sans-serif' },
        container: { maxWidth: '600px', backgroundColor: '#f5f5f5' },
      },
    },
  });
});

References

  • Maily render ThemeOptions: libs/maily-render/src/maily.tsx:87-116
  • Maily setTheme(): libs/maily-render/src/maily.tsx — deep merges with DEFAULT_THEME
  • Maily render() accepting theme: libs/maily-render/src/render.ts
  • Existing theme test: libs/maily-render/src/render.test.ts:184-217
  • API renderer (theme not passed): apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.usecase.ts:474
  • Email control schema (no theme field): libs/application-generic/src/schemas/control/email-control.schema.ts:7-24
  • Upstream theme definition: packages/shared/src/theme.ts

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions