From 094d3ee4caad90956318a267b3338b1a9284e021 Mon Sep 17 00:00:00 2001 From: Cynthia Date: Sun, 12 Nov 2023 15:09:25 +0100 Subject: [PATCH 1/6] feat: setup react-email for html emails --- email/.eslintrc.json | 38 + email/.gitignore | 2 + email/.prettierrc.json | 5 + email/HACKING.md | 179 + email/components/For.ts | 39 + email/components/If.ts | 47 + email/components/ImgResource.ts | 52 + email/components/Layout.tsx | 226 + email/components/LocalizedText.ts | 27 + email/components/Var.ts | 31 + email/components/parts/TolgeeButton.ts | 29 + email/components/parts/TolgeeLink.ts | 28 + email/components/translate.ts | 40 + email/emails/registration-confirm.tsx | 84 + email/package-lock.json | 12806 +++++++++++++++++++++++ email/package.json | 36 + email/resources/facebook.png | Bin 0 -> 317 bytes email/resources/github.png | Bin 0 -> 359 bytes email/resources/linkedin.png | Bin 0 -> 354 bytes email/resources/slack.png | Bin 0 -> 441 bytes email/resources/tolgee_logo_text.png | Bin 0 -> 1871 bytes email/resources/twitter.png | Bin 0 -> 294 bytes 22 files changed, 13669 insertions(+) create mode 100644 email/.eslintrc.json create mode 100644 email/.gitignore create mode 100644 email/.prettierrc.json create mode 100644 email/HACKING.md create mode 100644 email/components/For.ts create mode 100644 email/components/If.ts create mode 100644 email/components/ImgResource.ts create mode 100644 email/components/Layout.tsx create mode 100644 email/components/LocalizedText.ts create mode 100644 email/components/Var.ts create mode 100644 email/components/parts/TolgeeButton.ts create mode 100644 email/components/parts/TolgeeLink.ts create mode 100644 email/components/translate.ts create mode 100644 email/emails/registration-confirm.tsx create mode 100644 email/package-lock.json create mode 100644 email/package.json create mode 100644 email/resources/facebook.png create mode 100644 email/resources/github.png create mode 100644 email/resources/linkedin.png create mode 100644 email/resources/slack.png create mode 100644 email/resources/tolgee_logo_text.png create mode 100644 email/resources/twitter.png diff --git a/email/.eslintrc.json b/email/.eslintrc.json new file mode 100644 index 0000000000..e28ccf80b9 --- /dev/null +++ b/email/.eslintrc.json @@ -0,0 +1,38 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "ignorePatterns": ["**/*.generated.*", "*.js"], + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:@typescript-eslint/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": 12, + "sourceType": "module" + }, + "plugins": ["react", "@typescript-eslint", "prettier"], + "settings": { "react": { "version": "detect" } }, + "rules": { + "prettier/prettier": "error", + "no-console": "warn", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/ban-ts-comment": "off", + "react/react-in-jsx-scope": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { "args": "none", "varsIgnorePattern": "^_" } + ], + "@typescript-eslint/no-non-null-assertion": "off", + "react/prop-types": "off", + "@typescript-eslint/no-empty-interface": "off" + } +} diff --git a/email/.gitignore b/email/.gitignore new file mode 100644 index 0000000000..ceb1741918 --- /dev/null +++ b/email/.gitignore @@ -0,0 +1,2 @@ +.react-email +out diff --git a/email/.prettierrc.json b/email/.prettierrc.json new file mode 100644 index 0000000000..65261d68f4 --- /dev/null +++ b/email/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "singleQuote": true +} diff --git a/email/HACKING.md b/email/HACKING.md new file mode 100644 index 0000000000..8f9484c8c0 --- /dev/null +++ b/email/HACKING.md @@ -0,0 +1,179 @@ +# Guide to writing emails for Tolgee +This is a resource helpful for people contributing to Tolgee who might face the need to create new emails: this +document is a quick summary of how to write emails using React Email and get familiar with the internal tools and the +expected way emails should be written. + +## React Email basics +### Why React Email +The use of [React Email](https://react.email/) allows quickly writing emails using clear JSX syntax, which gets turned +into HTML code tailored specifically for compatibility with email clients. This sounds like nothing, but open up one +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. ;) + +React Email exposes a handful of primitives documented on their [website](https://react.email/docs/introduction). +If you need real world examples, they provide a bunch of great examples based on real-world emails written using +React Email [here](https://demo.react.email/preview/stack-overflow-tips). + +### Preview and build +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 +your browser. This allows for convenient workflow without having to send the emails to yourself just to test. + +You'll see below how to deal with variables, and how to have test data to see how it looks still without resorting +to manual testing within Tolgee itself. + +To build emails, simply run `npm run build`. This will output [Thymeleaf](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html) +templates in the `out` folder that the backend will be able to consume and render. + +The resources used by emails stored in `resources` must be served by the backend at `/static/emails`. Filenames must +be preserved. + +### TailwindCSS +For styles, React Email has a great [TailwindCSS](https://tailwindcss.com/) integration that gets turned into +email-friendly inline styles. + +When using styles, make sure to use things that are "email friendly". That means, no flexbox, no grid, and pretty much +anything that's cool in \[CURRENT_YEAR]. [Can I Email](https://www.caniemail.com/) is a good resource for what is +fine to send and what isn't; basically the [Can I Use](https://caniuse.com/) of emails. + +This also applies to the layout; always prefer React Email's `Container`, `Row` and `Column` elements for layout. +They'll get turned into ugly HTML tables to do the layout - just like in the good ol' HTML days... + +## Base layout +The base layout is available in `components/Layout.tsx`. All components should use it, as it'll include the base +Tailwind configuration and all the main elements. + +The layout takes 2 properties: +- `subject` (required): displayed in the header of the email and be used to construct the actual email subject +- `sendReason` (required): important for anti-spam laws and must reflect the reason why a given email is sent + - Is it because they have an account? Is it because they enabled notifications? ... +- `extra` (optional): displayed at the very bottom, useful to insert an unsubscribe link if necessary + +These three properties are generally expected to receive output from the `t()` function documented below. + +## Utility components +This is note is left here for the lack of a better section: whenever you need a dynamic properties (e.g. href that +takes the value of a variable), you can prefix your attribute with `data-th-` and setting the value to a +[Thymeleaf Standard Expression](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#standard-expression-syntax). + +```jsx +Click here! +``` + +A few base components with default styles are available in `components/parts`, such as buttons. + +### `` and `t()` +Most if not all text in emails are expected to be wrapped in `` (or `t()` when more appropriate). +They are equivalent as `` is simply a JSX wrapper for calling `t()`. + +The `` takes the following properties: +- `keyName` (required): String key name +- `defaultValue` (optional): Default string to use + - Will be used by the CLI to push default values when pushing new keys, and when previewing +- `demoProps` (required*): Demo properties to use when rendering the string + - When previewing, the ICU string will be rendered using these values, so it is representative of a "real" email + - If demo props are not specified, the preview will fail to render + - \*It can be unset if there are no props in the string + +The `t()` function takes the same properties, instead it takes them as arguments in the order they're described here. + +When using the development environment, only the default value locally provided will be considered. Strings are not +pulled from Tolgee to test directly within the dev environment. (At least, at this time). + +```tsx + + +t('hello', 'Hello {name}!', { name: 'Bob' }) +``` + +#### Considerations for the renderer +Newlines are handled as if there was an explicit `
`. This is handled by the previewer, and must be correctly +handled by the renderer (by replacing all newlines from ICU format output by `
`). + +The ICU renderer **MUST** sanitize the strings, to prevent HTML injection attacks. + +### `` +Injects a variable as plaintext. Easy, simple. Only useful when a variable is used outside an ICU string. + +It takes the following arguments: +- `variable` (required): name of the variable +- `demoValue` (required): value used for the preview +- `injectHtml` (optional): whether to inject this variable as raw HTML. Defaults to `false` + +### `` +If you want to use images, images should be placed in the `resources` folder and then this component should be used. +It functions like [React Email's ``](https://react.email/docs/components/image), except it doesn't take a +`src` prop but a `resource`, that should be the name of the file you want to insert. + +Be careful, [**SVG images are poorly supported**](https://www.caniemail.com/features/image-svg/) and therefore should +be avoided. PNG, JPG, and GIF should be good. + +It is also very important that files are **never** deleted, and preferably not modified. Doing so would alter +previously sent emails, by modifying images shown when opening them or leading to broken images. + +### `` +This allows for a conditionally showing a part of the email (and optionally showing something else instead). +This component takes exactly one or two children: the `true` case and the `false` case. They MUST render to a real +HTML node with the properties it received set to the HTML element. That's a lot of words to say they must NOT be +Fragments, but real nodes such as a `
` or a `` etc. + +It receives the following properties: +- `condition` (required): the [Thymeleaf conditional expression](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#conditional-expressions) +- `demoValue` (optional): the demo value. Defaults to `true` + +### `` +When dealing with a list of items, this component allows iterating over each element of the array and produce the +inner HTML for each element of the array. + +This component receives the following properties: +- `each` (required): The [Thymeleaf iterator expression](https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#using-theach) +- `demoIterations` (required): An array of elements used for the preview + +#### Note on available variables +Within the for inner template, the iter variable is available as a classic Thymeleaf variable. However, within ICU +strings, if the iter variable is an object, all the fields are available as plain variables prefixed by `$it_`. +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). + +All of these variables still have to be set as `demoProps` for the template to render properly in preview mode. + +Example: +```jsx + + + + + + + + + + + + + +``` + +## Global variables +The following global variables are available: +- `isCloud` (boolean): Whether this is Tolgee Cloud or not +- `instanceQualifier`: Either "Tolgee" for Tolgee Cloud, or the domain name used for the instance +- `instanceUrl`: Base URL of the instance + +They still need to be passed as demo values except for localized strings where a default value is provided. +The default value can be overridden. diff --git a/email/components/For.ts b/email/components/For.ts new file mode 100644 index 0000000000..a4a4039a94 --- /dev/null +++ b/email/components/For.ts @@ -0,0 +1,39 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from 'react'; + +type Props = { + each: string; + demoIterations?: number; + children: React.ReactElement; +}; + +export default function For({ each, demoIterations, children }: Props) { + if (process.env.NODE_ENV === 'production') { + return React.cloneElement(children, { 'data-th-each': each }); + } + + return React.createElement( + React.Fragment, + {}, + Array(demoIterations || 1) + .fill(null) + .map(() => + React.cloneElement(children, { key: Math.random().toString() }) + ) + ); +} diff --git a/email/components/If.ts b/email/components/If.ts new file mode 100644 index 0000000000..06ab362f5e --- /dev/null +++ b/email/components/If.ts @@ -0,0 +1,47 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from 'react'; + +type Props = { + condition: string; + demoValue?: boolean; + children: React.ReactElement | [React.ReactElement, React.ReactElement]; +}; + +export default function If({ + condition, + demoValue, + children: _children, +}: Props) { + const children = Array.isArray(_children) ? _children : [_children]; + + if (process.env.NODE_ENV === 'production') { + const trueCase = React.cloneElement(children[0], { + 'data-th-if': condition, + }); + + const falseCase = + children.length === 2 + ? React.cloneElement(children[1], { 'data-th-unless': condition }) + : null; + + return React.createElement(React.Fragment, {}, trueCase, falseCase); + } + + if (demoValue === false) return children[1]; + return children[0]; +} diff --git a/email/components/ImgResource.ts b/email/components/ImgResource.ts new file mode 100644 index 0000000000..5c445d6e7b --- /dev/null +++ b/email/components/ImgResource.ts @@ -0,0 +1,52 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from 'react'; +import { join, extname } from 'path'; +import { readFileSync, readdirSync } from 'fs'; +import { Img, ImgProps } from '@react-email/components'; + +let root = __dirname; +while (!readdirSync(root).includes('resources') && root !== '/') { + root = join(root, '..'); +} + +const RESOURCES_FOLDER = join(root, 'resources'); + +type Props = Omit & { + resourceName: string; +}; + +export default function ImgResource(props: Props) { + const file = join(RESOURCES_FOLDER, props.resourceName); + + const newProps = { ...props } as ImgProps & Props; + delete newProps.resourceName; + delete newProps.src; + + if (process.env.NODE_ENV === 'production') { + // Resources will be copied during final assembly. + newProps[ + 'data-th-src' + ] = `\${instanceUrl} + '/static/emails/${props.resourceName}'`; + } else { + const blob = readFileSync(file); + const ext = extname(file).slice(1); + newProps.src = `data:image/${ext};base64,${blob.toString('base64')}`; + } + + return React.createElement(Img, newProps); +} diff --git a/email/components/Layout.tsx b/email/components/Layout.tsx new file mode 100644 index 0000000000..53b895eeac --- /dev/null +++ b/email/components/Layout.tsx @@ -0,0 +1,226 @@ +/** + * Copyright (C) 2023 Tolgee s.r.o. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from 'react'; +import { renderToString } from 'react-dom/server'; +import { convert } from 'html-to-text'; + +import { + Html, + Head, + Tailwind, + Body, + Container, + Section, + Column, + Row, + Heading, + Hr, + Link, + Text, +} from '@react-email/components'; +import If from './If'; +import ImgResource from './ImgResource'; +import LocalizedText from './LocalizedText'; + +type Props = { + children: React.ReactNode; + subject: React.ReactElement; + sendReason: React.ReactElement; +}; + +export default function Layout({ children, subject, sendReason }: Props) { + const subjectPlainText = convert(renderToString(subject)); + + return ( + + + + {subjectPlainText} + + {process.env.NODE_ENV !== 'production' && ( + // This is a hack to get line returns to behave as line returns. + // The Kotlin renderer will handle these cases, but this is for the browser preview. + // white-space is poorly supported in email clients anyway. +