Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ jobs:
env: {}
- package: packages/postmaster
env: {}
- package: packages/mjml
env: {}

env:
PGHOST: localhost
Expand Down
12 changes: 12 additions & 0 deletions packages/mjml/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Change Log

All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.

## 1.0.0 (unreleased)

### Features

- Port @launchql/mjml to TypeScript as @constructive-io/mjml
- `generate(options)` to produce email HTML
- Export Header, Footer, ButtonWithMessage and types for custom templates
59 changes: 59 additions & 0 deletions packages/mjml/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# @constructive-io/mjml

<p align="center" width="100%">
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
</p>

<p align="center" width="100%">
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
</a>
<a href="https://github.com/constructive-io/constructive/blob/main/LICENSE">
<img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/>
</a>
<a href="https://www.npmjs.com/package/@constructive-io/mjml">
<img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=packages%2Fmjml%2Fpackage.json"/>
</a>
</p>

> MJML email HTML templates for Constructive

Generates responsive email HTML from a single `generate()` call with configurable header, message + CTA button, and footer. Peer dependencies: `react` (>=16), `react-dom` (>=16).

## Installation

```bash
npm install @constructive-io/mjml
```

## Usage

```typescript
import { generate } from '@constructive-io/mjml';

const html = generate({
title: 'Confirm your email',
message: 'Click the button below to confirm.',
link: 'https://example.com/confirm?token=abc',
linkText: 'Confirm',
companyName: 'Acme, Inc.',
supportEmail: 'support@acme.com',
website: 'https://acme.com',
logo: 'https://acme.com/logo.png'
});

// Send html with @constructive-io/postmaster or any email sender
```

## API

### `generate(options?: GenerateOptions): string`

Returns email HTML. All options are optional; defaults provide a generic template.

Options: `title`, `link`, `linkText`, `message`, `subMessage`, `bodyBgColor`, `messageBgColor`, `messageTextColor`, `messageButtonBgColor`, `messageButtonTextColor`, `companyName`, `supportEmail`, `website`, `logo`, `headerBgColor`, `headerImageProps`.

### Components

- `Header`, `Footer`, `ButtonWithMessage` — exported for custom compositions with `mjml-react`.
- Types: `GenerateOptions`, `HeaderProps`, `FooterProps`, `ButtonWithMessageProps`, `HeaderImageProps`.
67 changes: 67 additions & 0 deletions packages/mjml/__tests__/generate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { generate } from '../src';

describe('generate', () => {
it('returns a non-empty HTML string', () => {
const html = generate();
expect(typeof html).toBe('string');
expect(html.length).toBeGreaterThan(0);
expect(html).toMatch(/<!DOCTYPE|<\s*html/i);
});

it('uses default content when called with no options', () => {
const html = generate();
expect(html).toContain('Acme, Inc.');
expect(html).toContain('support@example.com');
});

it('embeds title in output', () => {
const html = generate({ title: 'Confirm your email' });
expect(html).toContain('Confirm your email');
});

it('embeds message in output', () => {
const html = generate({ message: 'Please click the button below.' });
expect(html).toContain('Please click the button below.');
});

it('embeds link and linkText in output', () => {
const html = generate({
link: 'https://example.com/confirm',
linkText: 'Confirm now'
});
expect(html).toContain('https://example.com/confirm');
expect(html).toContain('Confirm now');
});

it('embeds companyName and supportEmail in output', () => {
const html = generate({
companyName: 'Acme Corp',
supportEmail: 'help@acme.com'
});
expect(html).toContain('Acme Corp');
expect(html).toContain('help@acme.com');
});

it('embeds subMessage when provided as string', () => {
const html = generate({
message: 'Hello',
subMessage: 'Extra line here.'
});
expect(html).toContain('Extra line here.');
});

it('includes header logo link when website and logo are set', () => {
const html = generate({
website: 'https://acme.com',
logo: 'https://acme.com/logo.png'
});
expect(html).toContain('https://acme.com');
expect(html).toContain('https://acme.com/logo.png');
});

it('includes "or copy and paste" fallback text for link', () => {
const html = generate({ link: 'https://x.com' });
expect(html).toContain('or copy and paste this link');
expect(html).toContain('https://x.com');
});
});
18 changes: 18 additions & 0 deletions packages/mjml/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
babelConfig: false,
tsconfig: 'tsconfig.json'
}
]
},
transformIgnorePatterns: ['/node_modules/*'],
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*']
};
46 changes: 46 additions & 0 deletions packages/mjml/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"name": "@constructive-io/mjml",
"version": "1.0.0",
"author": "Constructive <developers@constructive.io>",
"description": "MJML email HTML templates for Constructive",
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
"homepage": "https://github.com/constructive-io/constructive",
"license": "MIT",
"publishConfig": {
"access": "public",
"directory": "dist"
},
"repository": {
"type": "git",
"url": "https://github.com/constructive-io/constructive"
},
"bugs": {
"url": "https://github.com/constructive-io/constructive/issues"
},
"scripts": {
"clean": "makage clean",
"prepack": "npm run build",
"build": "makage build",
"build:dev": "makage build --dev",
"lint": "eslint . --fix",
"test": "jest --passWithNoTests",
"test:watch": "jest --watch"
},
"dependencies": {
"mjml-react": "^1.0.59",
"react": "^18.0.0"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/react": "^18.0.0",
"makage": "^0.1.12",
"react-dom": "^18.0.0"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
},
"keywords": ["mjml", "email", "html", "constructive", "templates"]
}
81 changes: 81 additions & 0 deletions packages/mjml/src/components/ButtonWithMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
MjmlButton as Button,
MjmlColumn as Column,
MjmlSection as Section,
MjmlText as Text
} from 'mjml-react';

import type { ButtonWithMessageProps } from '../types';

const fontFamily =
'Ubuntu, Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif';

export function ButtonWithMessage({
link,
message,
subMessage,
linkText,
bodyBgColor = 'white',
bodyTextColor = '#414141',
buttonBgColor = '#414141',
buttonTextColor = 'white'
}: ButtonWithMessageProps = {}) {
return (
<>
<Section backgroundColor={bodyBgColor} paddingBottom="0px" paddingTop="0">
<Column width="100%" verticalAlign="top">
<Text
align="center"
color={bodyTextColor}
fontFamily={fontFamily}
fontSize="13px"
paddingLeft="25px"
paddingRight="25px"
paddingBottom="0px"
paddingTop="0"
>
<p>
<span style={{ fontSize: '27px' }}>
<span style={{ fontWeight: 'bold' }}>
<span style={{ color: bodyTextColor }}>{message}</span>
</span>
</span>
</p>
{subMessage != null ? subMessage : null}
</Text>
<Button
backgroundColor={buttonBgColor}
color={buttonTextColor}
fontSize="15px"
align="center"
verticalAlign="middle"
border="none"
padding="15px 30px"
borderRadius="3px"
href={link}
fontFamily={fontFamily}
paddingLeft="25px"
paddingRight="25px"
paddingBottom="25px"
paddingTop="25px"
>
{linkText}
</Button>
<Text
align="center"
color={bodyTextColor}
fontFamily={fontFamily}
fontSize="11px"
paddingLeft="25px"
paddingRight="25px"
paddingBottom="0px"
paddingTop="0px"
>
<p>or copy and paste this link into your browser:</p>
<p>{link}</p>
</Text>
</Column>
</Section>
</>
);
}
60 changes: 60 additions & 0 deletions packages/mjml/src/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
MjmlColumn as Column,
MjmlSection as Section,
MjmlText as Text
} from 'mjml-react';

import type { FooterProps } from '../types';

export function Footer({
companyName = 'Acme, Inc.',
supportEmail = 'support@example.com'
}: FooterProps = {}) {
return (
<>
<Section backgroundColor="#ffffff" paddingBottom="20px" paddingTop="20">
<Column width="100%" verticalAlign="top">
<Text
align="center"
color="#000"
fontFamily="Ubuntu, Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif"
fontSize="13px"
paddingLeft="25px"
paddingRight="25px"
paddingBottom="10px"
paddingTop="10"
>
<p>Any questions, comments, concerns?</p>
<p>
Contact our support staff at{' '}
<a
href={`mailto:${supportEmail}`}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<span style={{ fontWeight: 'bold' }}>{supportEmail}</span>
</a>
</p>
</Text>
</Column>
</Section>
<Section locked="true" paddingBottom="20px" paddingTop="20">
<Column width="100%" verticalAlign="middle">
<Text
align="center"
color="#000000"
fontFamily="Ubuntu, Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif"
fontSize="11px"
locked="true"
editable="false"
paddingLeft="25px"
paddingRight="25px"
paddingBottom="0px"
paddingTop="0"
>
<p style={{ fontSize: '11px' }}>{companyName}</p>
</Text>
</Column>
</Section>
</>
);
}
Loading