Skip to content

Commit ba8ac37

Browse files
committed
feat: setup react-email for html emails
1 parent 62b95f3 commit ba8ac37

22 files changed

+13669
-0
lines changed

email/.eslintrc.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"env": {
3+
"browser": true,
4+
"es2021": true
5+
},
6+
"ignorePatterns": ["**/*.generated.*", "*.js"],
7+
"extends": [
8+
"eslint:recommended",
9+
"plugin:react/recommended",
10+
"plugin:@typescript-eslint/recommended"
11+
],
12+
"parser": "@typescript-eslint/parser",
13+
"parserOptions": {
14+
"ecmaFeatures": {
15+
"jsx": true
16+
},
17+
"ecmaVersion": 12,
18+
"sourceType": "module"
19+
},
20+
"plugins": ["react", "@typescript-eslint", "prettier"],
21+
"settings": { "react": { "version": "detect" } },
22+
"rules": {
23+
"prettier/prettier": "error",
24+
"no-console": "warn",
25+
"@typescript-eslint/no-explicit-any": "off",
26+
"@typescript-eslint/ban-ts-comment": "off",
27+
"react/react-in-jsx-scope": "off",
28+
"@typescript-eslint/explicit-module-boundary-types": "off",
29+
"@typescript-eslint/no-empty-function": "off",
30+
"@typescript-eslint/no-unused-vars": [
31+
"warn",
32+
{ "args": "none", "varsIgnorePattern": "^_" }
33+
],
34+
"@typescript-eslint/no-non-null-assertion": "off",
35+
"react/prop-types": "off",
36+
"@typescript-eslint/no-empty-interface": "off"
37+
}
38+
}

email/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.react-email
2+
out

email/.prettierrc.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"trailingComma": "es5",
3+
"tabWidth": 2,
4+
"singleQuote": true
5+
}

email/HACKING.md

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# Guide to writing emails for Tolgee
2+
This is a resource helpful for people contributing to Tolgee who might face the need to create new emails: this
3+
document is a quick summary of how to write emails using React Email and get familiar with the internal tools and the
4+
expected way emails should be written.
5+
6+
## React Email basics
7+
### Why React Email
8+
The use of [React Email](https://react.email/) allows quickly writing emails using clear JSX syntax, which gets turned
9+
into HTML code tailored specifically for compatibility with email clients. This sounds like nothing, but open up one
10+
of the output HTML files, and you'll see for yourself why it's such a big deal to have a tool do it for you. ;)
11+
12+
React Email exposes a handful of primitives documented on their [website](https://react.email/docs/introduction).
13+
If you need real world examples, they provide a bunch of great examples based on real-world emails written using
14+
React Email [here](https://demo.react.email/preview/stack-overflow-tips).
15+
16+
### Preview and build
17+
While working on emails, you can use `npm run dev` to spin up a dev server and have a live preview of the emails in
18+
your browser. This allows for convenient workflow without having to send the emails to yourself just to test.
19+
20+
You'll see below how to deal with variables, and how to have test data to see how it looks still without resorting
21+
to manual testing within Tolgee itself.
22+
23+
To build emails, simply run `npm run build`. This will output [Thymeleaf](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html)
24+
templates in the `out` folder that the backend will be able to consume and render.
25+
26+
The resources used by emails stored in `resources` must be served by the backend at `/static/emails`. Filenames must
27+
be preserved.
28+
29+
### TailwindCSS
30+
For styles, React Email has a great [TailwindCSS](https://tailwindcss.com/) integration that gets turned into
31+
email-friendly inline styles.
32+
33+
When using styles, make sure to use things that are "email friendly". That means, no flexbox, no grid, and pretty much
34+
anything that's cool in \[CURRENT_YEAR]. [Can I Email](https://www.caniemail.com/) is a good resource for what is
35+
fine to send and what isn't; basically the [Can I Use](https://caniuse.com/) of emails.
36+
37+
This also applies to the layout; always prefer React Email's `Container`, `Row` and `Column` elements for layout.
38+
They'll get turned into ugly HTML tables to do the layout - just like in the good ol' HTML days...
39+
40+
## Base layout
41+
The base layout is available in `components/Layout.tsx`. All components should use it, as it'll include the base
42+
Tailwind configuration and all the main elements.
43+
44+
The layout takes 2 properties:
45+
- `subject` (required): displayed in the header of the email and be used to construct the actual email subject
46+
- `sendReason` (required): important for anti-spam laws and must reflect the reason why a given email is sent
47+
- Is it because they have an account? Is it because they enabled notifications? ...
48+
- `extra` (optional): displayed at the very bottom, useful to insert an unsubscribe link if necessary
49+
50+
These three properties are generally expected to receive output from the `t()` function documented below.
51+
52+
## Utility components
53+
This is note is left here for the lack of a better section: whenever you need a dynamic properties (e.g. href that
54+
takes the value of a variable), you can prefix your attribute with `data-th-` and setting the value to a
55+
[Thymeleaf Standard Expression](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#standard-expression-syntax).
56+
57+
```jsx
58+
<a data-th-href="${link}">Click here!</a>
59+
```
60+
61+
A few base components with default styles are available in `components/parts`, such as buttons.
62+
63+
### `<LocalizedText />` and `t()`
64+
Most if not all text in emails are expected to be wrapped in `<LocalizedText />` (or `t()` when more appropriate).
65+
They are equivalent as `<LocalizedText />` is simply a JSX wrapper for calling `t()`.
66+
67+
The `<LocalizedText />` takes the following properties:
68+
- `keyName` (required): String key name
69+
- `defaultValue` (optional): Default string to use
70+
- Will be used by the CLI to push default values when pushing new keys, and when previewing
71+
- `demoProps` (required*): Demo properties to use when rendering the string
72+
- When previewing, the ICU string will be rendered using these values, so it is representative of a "real" email
73+
- If demo props are not specified, the preview will fail to render
74+
- \*It can be unset if there are no props in the string
75+
76+
The `t()` function takes the same properties, instead it takes them as arguments in the order they're described here.
77+
78+
When using the development environment, only the default value locally provided will be considered. Strings are not
79+
pulled from Tolgee to test directly within the dev environment. (At least, at this time).
80+
81+
```tsx
82+
<LocalizedText
83+
keyName="hello"
84+
defaultValue="Hello {name}!"
85+
demoProps={{
86+
name: 'Bob'
87+
}}
88+
/>
89+
90+
t('hello', 'Hello {name}!', { name: 'Bob' })
91+
```
92+
93+
#### Considerations for the renderer
94+
Newlines are handled as if there was an explicit `<br />`. This is handled by the previewer, and must be correctly
95+
handled by the renderer (by replacing all newlines from ICU format output by `<br />`).
96+
97+
The ICU renderer **MUST** sanitize the strings, to prevent HTML injection attacks.
98+
99+
### `<Var />`
100+
Injects a variable as plaintext. Easy, simple. Only useful when a variable is used outside an ICU string.
101+
102+
It takes the following arguments:
103+
- `variable` (required): name of the variable
104+
- `demoValue` (required): value used for the preview
105+
- `injectHtml` (optional): whether to inject this variable as raw HTML. Defaults to `false`
106+
107+
### `<ImgResource />`
108+
If you want to use images, images should be placed in the `resources` folder and then this component should be used.
109+
It functions like [React Email's `<Img />`](https://react.email/docs/components/image), except it doesn't take a
110+
`src` prop but a `resource`, that should be the name of the file you want to insert.
111+
112+
Be careful, [**SVG images are poorly supported**](https://www.caniemail.com/features/image-svg/) and therefore should
113+
be avoided. PNG, JPG, and GIF should be good.
114+
115+
It is also very important that files are **never** deleted, and preferably not modified. Doing so would alter
116+
previously sent emails, by modifying images shown when opening them or leading to broken images.
117+
118+
### `<If />`
119+
This allows for a conditionally showing a part of the email (and optionally showing something else instead).
120+
This component takes exactly one or two children: the `true` case and the `false` case. They MUST render to a real
121+
HTML node with the properties it received set to the HTML element. That's a lot of words to say they must NOT be
122+
Fragments, but real nodes such as a `<div />` or a `<Container />` etc.
123+
124+
It receives the following properties:
125+
- `condition` (required): the [Thymeleaf conditional expression](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#conditional-expressions)
126+
- `demoValue` (optional): the demo value. Defaults to `true`
127+
128+
### `<For />`
129+
When dealing with a list of items, this component allows iterating over each element of the array and produce the
130+
inner HTML for each element of the array.
131+
132+
This component receives the following properties:
133+
- `each` (required): The [Thymeleaf iterator expression](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#using-theach)
134+
- `demoIterations` (required): An array of elements used for the preview
135+
136+
#### Note on available variables
137+
Within the for inner template, the iter variable is available as a classic Thymeleaf variable. However, within ICU
138+
strings, if the iter variable is an object, all the fields are available as plain variables prefixed by `$it_`.
139+
Information about the iteration can be kept by using [Thymeleaf iterator status mechanism](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#keeping-iteration-status).
140+
141+
All of these variables still have to be set as `demoProps` for the template to render properly in preview mode.
142+
143+
Example:
144+
```jsx
145+
<For each="product, iterStat : ${products}" demoIterations={2}>
146+
<tr>
147+
<td>
148+
<Var variable="iterStat.index" demoValue="1" />
149+
</td>
150+
<td>
151+
<LocalizedText
152+
keyName="product-id"
153+
defaultValue="Product #{$it_id}"
154+
demoParams={{
155+
$it_id: 1337
156+
}}
157+
/>
158+
</td>
159+
<td>
160+
<LocalizedText
161+
keyName="product-price"
162+
defaultValue="Price: {$it_id, number}"
163+
demoParams={{
164+
$it_id: 4.00
165+
}}
166+
/>
167+
</td>
168+
</tr>
169+
</For>
170+
```
171+
172+
## Global variables
173+
The following global variables are available:
174+
- `isCloud` (boolean): Whether this is Tolgee Cloud or not
175+
- `instanceQualifier`: Either "Tolgee" for Tolgee Cloud, or the domain name used for the instance
176+
- `instanceUrl`: Base URL of the instance
177+
178+
They still need to be passed as demo values except for localized strings where a default value is provided.
179+
The default value can be overridden.

email/components/For.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Copyright (C) 2023 Tolgee s.r.o. and contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import * as React from 'react';
18+
19+
type Props = {
20+
each: string;
21+
demoIterations?: number;
22+
children: React.ReactElement;
23+
};
24+
25+
export default function For({ each, demoIterations, children }: Props) {
26+
if (process.env.NODE_ENV === 'production') {
27+
return React.cloneElement(children, { 'data-th-each': each });
28+
}
29+
30+
return React.createElement(
31+
React.Fragment,
32+
{},
33+
Array(demoIterations || 1)
34+
.fill(null)
35+
.map(() =>
36+
React.cloneElement(children, { key: Math.random().toString() })
37+
)
38+
);
39+
}

email/components/If.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Copyright (C) 2023 Tolgee s.r.o. and contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import * as React from 'react';
18+
19+
type Props = {
20+
condition: string;
21+
demoValue?: boolean;
22+
children: React.ReactElement | [React.ReactElement, React.ReactElement];
23+
};
24+
25+
export default function If({
26+
condition,
27+
demoValue,
28+
children: _children,
29+
}: Props) {
30+
const children = Array.isArray(_children) ? _children : [_children];
31+
32+
if (process.env.NODE_ENV === 'production') {
33+
const trueCase = React.cloneElement(children[0], {
34+
'data-th-if': condition,
35+
});
36+
37+
const falseCase =
38+
children.length === 2
39+
? React.cloneElement(children[1], { 'data-th-unless': condition })
40+
: null;
41+
42+
return React.createElement(React.Fragment, {}, trueCase, falseCase);
43+
}
44+
45+
if (demoValue === false) return children[1];
46+
return children[0];
47+
}

email/components/ImgResource.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Copyright (C) 2023 Tolgee s.r.o. and contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import * as React from 'react';
18+
import { join, extname } from 'path';
19+
import { readFileSync, readdirSync } from 'fs';
20+
import { Img, ImgProps } from '@react-email/components';
21+
22+
let root = __dirname;
23+
while (!readdirSync(root).includes('resources') && root !== '/') {
24+
root = join(root, '..');
25+
}
26+
27+
const RESOURCES_FOLDER = join(root, 'resources');
28+
29+
type Props = Omit<ImgProps, 'src'> & {
30+
resourceName: string;
31+
};
32+
33+
export default function ImgResource(props: Props) {
34+
const file = join(RESOURCES_FOLDER, props.resourceName);
35+
36+
const newProps = { ...props } as ImgProps & Props;
37+
delete newProps.resourceName;
38+
delete newProps.src;
39+
40+
if (process.env.NODE_ENV === 'production') {
41+
// Resources will be copied during final assembly.
42+
newProps[
43+
'data-th-src'
44+
] = `\${instanceUrl} + '/static/emails/${props.resourceName}'`;
45+
} else {
46+
const blob = readFileSync(file);
47+
const ext = extname(file).slice(1);
48+
newProps.src = `data:image/${ext};base64,${blob.toString('base64')}`;
49+
}
50+
51+
return React.createElement(Img, newProps);
52+
}

0 commit comments

Comments
 (0)