diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2168541c..b444e576 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -186,6 +186,11 @@ jobs: pnpm --filter @unlayer/content-workflows-example typecheck pnpm --filter @unlayer/content-workflows-example test + - name: i18n example (react-intl, react-i18next, and next-intl in English and Arabic) + run: | + pnpm --filter @unlayer/i18n-example typecheck + pnpm --filter @unlayer/i18n-example test + # ── Browser import/runtime safety gate ────────────────────── # Bundles the shipped package entry as a browser consumer would, then # imports and renders inside Chromium without a Node `process` global. diff --git a/README.md b/README.md index 960d31a7..f97478ef 100644 --- a/README.md +++ b/README.md @@ -261,12 +261,14 @@ import { Row, Column, ColumnLayouts } from '@unlayer/react-elements'; For the full API reference, component props, design patterns, and common mistakes, see the **[React package documentation](./packages/react/README.md)**. +Using a coding agent? Install the official **[`unlayer-elements` skill](https://github.com/unlayer/unlayer-skills)** (`npx skills add unlayer/unlayer-skills`): the structure, layouts, renderers, preview commands, and common mistakes. + ## Packages | Package | Description | Published | |---------|-------------|-----------| | [`@unlayer/react-elements`](./packages/react) | React components and renderers | [![npm](https://img.shields.io/npm/v/@unlayer/react-elements.svg)](https://www.npmjs.com/package/@unlayer/react-elements) | -| [`@unlayer/migrate`](./packages/migrate) | Migrate React Email templates to Elements, checked against the originals | Not yet | +| [`@unlayer/migrate`](./packages/migrate) | Migrate React Email, MJML and Beefree templates to Elements, checked against the originals | [![npm](https://img.shields.io/npm/v/@unlayer/migrate.svg)](https://www.npmjs.com/package/@unlayer/migrate) | | [`@unlayer/from-react-email`](./packages/from-react-email) | The React Email → Elements converter, as a library | Private (bundled into `@unlayer/migrate`, API at `@unlayer/migrate/react-email`) | | [`@unlayer/convert-core`](./packages/convert-core) | Shared model for converters (Elements tree, report, TSX printer, layout check) | Internal | | [`@unlayer-internal/shared-elements`](./packages/shared) | Framework-agnostic shared logic | Internal | @@ -278,7 +280,8 @@ Runnable projects you can clone and experiment with: | Example | Description | |---------|-------------| -| [`examples/content-workflows`](./examples/content-workflows) | One React invoice → email + web + real PDF, five email providers, and editable design JSON | +| [`examples/content-workflows`](./examples/content-workflows) | One React invoice → email + web + real PDF, fourteen email providers, and editable design JSON | +| [`examples/i18n`](./examples/i18n) | One email in English and Arabic with react-intl, react-i18next, and next-intl, tested for translated text, `lang`, and `dir` | | [`examples/nextjs-app-router`](./examples/nextjs-app-router) | Next.js 15 App Router — renders an email with `renderToHtml()` in a Server Component, previews it at `/email-preview`, and serves the raw HTML from a Route Handler | ## Development @@ -342,8 +345,10 @@ Yes. `registerElementsTool()` turns a custom tool definition into a first-class Start with [when to use Elements](./packages/react/docs/README.md), or go directly to a runnable workflow: - [Build transactional emails in React](./packages/react/docs/transactional-email.md) — HTML and plain-text receipts, order confirmations, and password reset patterns. -- [Send with Resend, AWS SES, Postmark, SendGrid, or Nodemailer](./packages/react/docs/email-providers.md) — provider setup and complete server-side integrations. +- [Send with Resend, AWS SES, Postmark, SendGrid, Nodemailer, Mailgun, Brevo, and more](./packages/react/docs/email-providers.md) — provider setup and complete server-side integrations for fourteen providers. - [One React invoice to email, web, and PDF](./packages/react/docs/one-template-email-web-pdf.md) — share content across all three output modes. +- [Translate emails with react-intl, react-i18next, or next-intl](./packages/react/docs/i18n.md) — load messages before rendering, and set language, direction, and alignment for right-to-left locales. +- [Use Elements in a monorepo](./packages/react/docs/monorepo.md) — a shared emails package with npm, pnpm, Yarn, or Bun, and one copy of React. - [Generate a real PDF with Playwright](./packages/react/docs/react-to-pdf.md) — print document HTML with Chromium. - [Make code and AI-generated templates visually editable](./packages/react/docs/visual-editing.md) — `renderToJson()` → Unlayer `loadDesign()` → edited JSON and HTML. diff --git a/examples/content-workflows/README.md b/examples/content-workflows/README.md index ab1772c6..98bcd94b 100644 --- a/examples/content-workflows/README.md +++ b/examples/content-workflows/README.md @@ -1,6 +1,6 @@ # React transactional email, web, PDF, and visual editing example -One invoice template demonstrates `@unlayer/react-elements` rendering and delivery. Use this example for receipts, order confirmations, invoices, or AI-generated templates that need visual editing. All commands below run from the repository root with Node.js 20+ and pnpm 9. +One invoice template demonstrates `@unlayer/react-elements` rendering and delivery. Use this example for receipts, order confirmations, invoices, or AI-generated templates that need visual editing. All commands below run from the repository root with Node.js 20+ and pnpm 9 (Azure's SDK declares Node.js 22 or newer: use 22+ to send with Azure). ## Install and run @@ -41,7 +41,7 @@ The [provider guide](../../packages/react/docs/email-providers.md) lists install - `src/message.ts`: HTML and plain-text renderer plus environment handling. - `src/render-email.ts` / `src/render-all.ts`: output files. - `src/pdf.ts`: Playwright PDF generation and browser cleanup. -- `src/providers/`: Resend, SES, Postmark, SendGrid, and SMTP delivery. +- `src/providers/`: Resend, SES, Postmark, SendGrid, SMTP, Mailgun, Azure Communication Services, MailerSend, Scaleway, Plunk, Lettermint, Brevo, Inbound, and eusend delivery. - `editor.html`: wait for editor readiness, load design, export edits. ```bash @@ -49,4 +49,4 @@ pnpm --filter @unlayer/content-workflows-example typecheck pnpm --filter @unlayer/content-workflows-example test ``` -Tests check invoice content in Chromium across all three modes, plain text, editable JSON, escaped customer input, URL validation, and real PDF bytes. They do not send email or depend on the hosted editor. Review inbox compatibility and PDF pagination with your real content before deployment. See the [task guides](../../packages/react/docs/README.md) for constraints and adaptation. +Tests check invoice content in Chromium across all three modes, plain text, editable JSON, escaped customer input, URL validation, and real PDF bytes. They also run the Mailgun, Azure, MailerSend, Scaleway, Plunk, Lettermint, Brevo, Inbound, and eusend scripts with [nock](https://github.com/nock/nock) answering their HTTP requests, and check what each one sends. They do not send email or depend on the hosted editor. Review inbox compatibility and PDF pagination with your real content before deployment. See the [task guides](../../packages/react/docs/README.md) for constraints and adaptation. diff --git a/examples/content-workflows/package.json b/examples/content-workflows/package.json index 2f847f3e..adf14d00 100644 --- a/examples/content-workflows/package.json +++ b/examples/content-workflows/package.json @@ -13,7 +13,16 @@ "send:ses": "tsx src/providers/ses.ts", "send:postmark": "tsx src/providers/postmark.ts", "send:sendgrid": "tsx src/providers/sendgrid.ts", - "send:nodemailer": "tsx src/providers/nodemailer.ts" + "send:nodemailer": "tsx src/providers/nodemailer.ts", + "send:mailgun": "tsx src/providers/mailgun.ts", + "send:azure": "tsx src/providers/azure.ts", + "send:mailersend": "tsx src/providers/mailersend.ts", + "send:scaleway": "tsx src/providers/scaleway.ts", + "send:plunk": "tsx src/providers/plunk.ts", + "send:lettermint": "tsx src/providers/lettermint.ts", + "send:brevo": "tsx src/providers/brevo.ts", + "send:inbound": "tsx src/providers/inbound.ts", + "send:eusend": "tsx src/providers/eusend.ts" }, "dependencies": { "@unlayer/react-elements": "workspace:*", @@ -24,7 +33,16 @@ "@aws-sdk/client-sesv2": "^3.0.0", "postmark": "^4.0.0", "@sendgrid/mail": "^8.1.0", - "nodemailer": "^7.0.0" + "nodemailer": "^7.0.0", + "mailgun.js": "14.1.0", + "@azure/communication-email": "1.1.0", + "mailersend": "3.4.1", + "@scaleway/sdk-client": "2.14.0", + "@scaleway/sdk-tem": "2.16.1", + "lettermint": "3.1.0", + "@getbrevo/brevo": "6.1.0", + "inboundemail": "0.20.0", + "@eusend_dev/sdk": "0.19.0" }, "devDependencies": { "@types/node": "^24.9.1", @@ -33,6 +51,7 @@ "@types/nodemailer": "^7.0.0", "tsx": "^4.20.5", "typescript": "^5.6.3", - "http-server": "14.1.1" + "http-server": "14.1.1", + "nock": "14.0.17" } } diff --git a/examples/content-workflows/src/providers/azure.ts b/examples/content-workflows/src/providers/azure.ts new file mode 100644 index 00000000..dfb58769 --- /dev/null +++ b/examples/content-workflows/src/providers/azure.ts @@ -0,0 +1,16 @@ +import { EmailClient } from "@azure/communication-email"; +import { deliveryMessage, requireEnv } from "../message"; + +const client = new EmailClient(requireEnv("AZURE_COMMUNICATION_CONNECTION_STRING")); +const { from, to, subject, html, text } = deliveryMessage(); +// beginSend queues the message and returns a poller for the send operation. +const poller = await client.beginSend({ + senderAddress: from, + recipients: { to: [{ address: to }] }, + content: { subject, html, plainText: text }, +}); +const result = await poller.pollUntilDone(); +if (result.status !== "Succeeded") { + throw new Error(`Azure Communication Services: ${result.error?.message ?? result.status}`); +} +console.info("Azure accepted:", result.id); diff --git a/examples/content-workflows/src/providers/brevo.ts b/examples/content-workflows/src/providers/brevo.ts new file mode 100644 index 00000000..06dee5f7 --- /dev/null +++ b/examples/content-workflows/src/providers/brevo.ts @@ -0,0 +1,13 @@ +import { BrevoClient } from "@getbrevo/brevo"; +import { deliveryMessage, requireEnv } from "../message"; + +const client = new BrevoClient({ apiKey: requireEnv("BREVO_API_KEY") }); +const { from, to, subject, html, text } = deliveryMessage(); +const result = await client.transactionalEmails.sendTransacEmail({ + sender: { email: from }, + to: [{ email: to }], + subject, + htmlContent: html, + textContent: text, +}); +console.info("Brevo accepted:", result.messageId); diff --git a/examples/content-workflows/src/providers/eusend.ts b/examples/content-workflows/src/providers/eusend.ts new file mode 100644 index 00000000..16ea473d --- /dev/null +++ b/examples/content-workflows/src/providers/eusend.ts @@ -0,0 +1,7 @@ +import { Eusend } from "@eusend_dev/sdk"; +import { deliveryMessage, requireEnv } from "../message"; + +const client = new Eusend(requireEnv("EUSEND_API_KEY")); +const { data, error } = await client.emails.send(deliveryMessage()); +if (error) throw new Error(`eusend: ${error.name} ${error.message}`); +console.info("eusend accepted:", data.id); diff --git a/examples/content-workflows/src/providers/inbound.ts b/examples/content-workflows/src/providers/inbound.ts new file mode 100644 index 00000000..cd4dfa7d --- /dev/null +++ b/examples/content-workflows/src/providers/inbound.ts @@ -0,0 +1,7 @@ +import Inbound from "inboundemail"; +import { deliveryMessage, requireEnv } from "../message"; + +const client = new Inbound({ apiKey: requireEnv("INBOUND_API_KEY") }); +const { from, to, subject, html, text } = deliveryMessage(); +const result = await client.emails.send({ from, to, subject, html, text }); +console.info("Inbound accepted:", result.id); diff --git a/examples/content-workflows/src/providers/lettermint.ts b/examples/content-workflows/src/providers/lettermint.ts new file mode 100644 index 00000000..b41356c1 --- /dev/null +++ b/examples/content-workflows/src/providers/lettermint.ts @@ -0,0 +1,7 @@ +import { Lettermint } from "lettermint"; +import { deliveryMessage, requireEnv } from "../message"; + +const client = new Lettermint({ sendingToken: requireEnv("LETTERMINT_PROJECT_TOKEN") }); +const { from, to, subject, html, text } = deliveryMessage(); +const result = await client.emails.send({ from, to: [to], subject, html, text }); +console.info("Lettermint accepted:", result.message_id); diff --git a/examples/content-workflows/src/providers/mailersend.ts b/examples/content-workflows/src/providers/mailersend.ts new file mode 100644 index 00000000..305d4198 --- /dev/null +++ b/examples/content-workflows/src/providers/mailersend.ts @@ -0,0 +1,13 @@ +import { EmailParams, MailerSend, Recipient, Sender } from "mailersend"; +import { deliveryMessage, requireEnv } from "../message"; + +const client = new MailerSend({ apiKey: requireEnv("MAILERSEND_API_KEY") }); +const { from, to, subject, html, text } = deliveryMessage(); +const params = new EmailParams() + .setFrom(new Sender(from)) + .setTo([new Recipient(to)]) + .setSubject(subject) + .setHtml(html) + .setText(text); +const response = await client.email.send(params); +console.info("MailerSend accepted:", response.statusCode, response.headers["x-message-id"]); diff --git a/examples/content-workflows/src/providers/mailgun.ts b/examples/content-workflows/src/providers/mailgun.ts new file mode 100644 index 00000000..01dfab9b --- /dev/null +++ b/examples/content-workflows/src/providers/mailgun.ts @@ -0,0 +1,19 @@ +import Mailgun from "mailgun.js"; +import { deliveryMessage, requireEnv } from "../message"; + +// mailgun.js builds a multipart request with the FormData that Node.js 18+ provides. +const client = new Mailgun(FormData).client({ + username: "api", + key: requireEnv("MAILGUN_API_KEY"), + // Domains in Mailgun's EU region use https://api.eu.mailgun.net. + url: process.env.MAILGUN_URL ?? "https://api.mailgun.net", +}); +const { from, to, subject, html, text } = deliveryMessage(); +const result = await client.messages.create(requireEnv("MAILGUN_DOMAIN"), { + from, + to: [to], + subject, + html, + text, +}); +console.info("Mailgun accepted:", result.id); diff --git a/examples/content-workflows/src/providers/plunk.ts b/examples/content-workflows/src/providers/plunk.ts new file mode 100644 index 00000000..960bf7f4 --- /dev/null +++ b/examples/content-workflows/src/providers/plunk.ts @@ -0,0 +1,20 @@ +import { deliveryMessage, requireEnv } from "../message"; + +// The Plunk npm package is deprecated, so this calls Plunk's REST API directly. +// Plunk derives the plain-text part from `body`; the API has no text field. +type PlunkResponse = + | { success: true; data: { emails: { email: string }[] } } + | { success: false; error: { code: string; message: string } }; + +const { from, to, subject, html } = deliveryMessage(); +const response = await fetch("https://next-api.useplunk.com/v1/send", { + method: "POST", + headers: { + Authorization: `Bearer ${requireEnv("PLUNK_SECRET_KEY")}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ from, to, subject, body: html }), +}); +const result = (await response.json()) as PlunkResponse; +if (!result.success) throw new Error(`Plunk ${response.status}: ${result.error.message}`); +console.info("Plunk accepted:", result.data.emails[0].email); diff --git a/examples/content-workflows/src/providers/scaleway.ts b/examples/content-workflows/src/providers/scaleway.ts new file mode 100644 index 00000000..0d9ab30b --- /dev/null +++ b/examples/content-workflows/src/providers/scaleway.ts @@ -0,0 +1,21 @@ +import { createClient } from "@scaleway/sdk-client"; +import { Temv1alpha1 } from "@scaleway/sdk-tem"; +import { deliveryMessage, requireEnv } from "../message"; + +const client = createClient({ + accessKey: requireEnv("SCW_ACCESS_KEY"), + secretKey: requireEnv("SCW_SECRET_KEY"), + defaultProjectId: requireEnv("SCW_DEFAULT_PROJECT_ID"), + // Transactional Email runs in the fr-par region. + defaultRegion: "fr-par", +}); +const tem = new Temv1alpha1.API(client); +const { from, to, subject, html, text } = deliveryMessage(); +const { emails } = await tem.createEmail({ + from: { email: from }, + to: [{ email: to }], + subject, + html, + text, +}); +console.info("Scaleway accepted:", emails.map((email) => email.id).join(", ")); diff --git a/examples/content-workflows/src/workflows.test.ts b/examples/content-workflows/src/workflows.test.ts index da0ab534..43571b6a 100644 --- a/examples/content-workflows/src/workflows.test.ts +++ b/examples/content-workflows/src/workflows.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { renderToHtml, renderToJson } from "@unlayer/react-elements"; +import nock from "nock"; import { chromium } from "playwright"; import { invoiceTemplate, sampleInvoice } from "./invoice"; import { renderInvoiceEmail } from "./message"; @@ -54,3 +55,184 @@ test("document HTML produces a real PDF", async () => { assert.equal(pdf.subarray(0, 5).toString(), "%PDF-"); assert.ok(pdf.length > 1000); }); + +// The provider scripts run as written; nock answers their HTTP requests, so nothing is sent. +type SentRequest = { headers: Record; body: nock.Body }; +type SentMessage = { from?: string; to?: string; subject?: string; html?: string; text?: string }; + +function record(interceptor: nock.Interceptor, status: number, response: nock.ReplyBody, headers = {}) { + const sent: SentRequest[] = []; + interceptor.reply(function (_uri, body) { + sent.push({ headers: this.req.headers, body }); + return [status, response, { "content-type": "application/json", ...headers }]; + }); + return sent; +} + +function json(body: nock.Body): Record { + return typeof body === "string" ? JSON.parse(body) : body; +} + +// multipart/form-data sends line breaks in field values as CRLF. +function multipartFields(body: nock.Body): Record { + const fields: Record = {}; + for (const [, name, value] of String(body).matchAll(/name="([^"]+)"\r\n(?:[^\r\n]+\r\n)*\r\n([\s\S]*?)\r\n--/g)) { + fields[name] = value.replace(/\r\n/g, "\n"); + } + return fields; +} + +const sender = "receipts@example.com"; +const recipient = "ada@example.com"; +const azureOrigin = "https://example-resource.communication.azure.com"; + +const providers: { + script: string; + env: Record; + mock: () => SentRequest[]; + authorized: (headers: Record) => boolean; + message: (body: nock.Body) => SentMessage; + printed: string; +}[] = [ + { + script: "mailgun", + env: { MAILGUN_API_KEY: "test-key", MAILGUN_DOMAIN: "mg.example.com" }, + mock: () => record(nock("https://api.mailgun.net").post("/v3/mg.example.com/messages"), 200, { id: "", message: "Queued. Thank you." }), + authorized: (headers) => headers.authorization === `Basic ${Buffer.from("api:test-key").toString("base64")}`, + message: (body) => { + const { from, to, subject, html, text } = multipartFields(body); + return { from, to, subject, html, text }; + }, + printed: "Mailgun accepted: ", + }, + { + script: "azure", + env: { AZURE_COMMUNICATION_CONNECTION_STRING: `endpoint=${azureOrigin}/;accesskey=${Buffer.from("test-key").toString("base64")}` }, + mock: () => { + nock(azureOrigin).get("/emails/operations/azure-1").query(true).reply(200, { id: "azure-1", status: "Succeeded" }); + return record(nock(azureOrigin).post("/emails:send").query(true), 202, { id: "azure-1", status: "Running" }, { + "operation-location": `${azureOrigin}/emails/operations/azure-1?api-version=2025-09-01`, + }); + }, + authorized: (headers) => headers.authorization.startsWith("HMAC-SHA256 "), + message: (body) => { + const { senderAddress, recipients, content } = json(body); + return { from: senderAddress, to: recipients.to[0].address, subject: content.subject, html: content.html, text: content.plainText }; + }, + printed: "Azure accepted: azure-1", + }, + { + script: "mailersend", + env: { MAILERSEND_API_KEY: "test-key" }, + mock: () => record(nock("https://api.mailersend.com").post("/v1/email"), 202, "", { "x-message-id": "mailersend-1" }), + authorized: (headers) => headers.authorization === "Bearer test-key", + message: (body) => { + const { from, to, subject, html, text } = json(body); + return { from: from.email, to: to[0].email, subject, html, text }; + }, + printed: "MailerSend accepted: 202 mailersend-1", + }, + { + script: "scaleway", + env: { + SCW_ACCESS_KEY: "SCWXXXXXXXXXXXXXXXXX", + SCW_SECRET_KEY: "11111111-1111-1111-1111-111111111111", + SCW_DEFAULT_PROJECT_ID: "22222222-2222-2222-2222-222222222222", + }, + mock: () => record(nock("https://api.scaleway.com").post("/transactional-email/v1alpha1/regions/fr-par/emails"), 200, { + emails: [{ id: "scaleway-1", message_id: "message-1", project_id: "22222222-2222-2222-2222-222222222222", mail_from: sender, mail_rcpt: recipient, rcpt_type: "to", subject: "Receipt", status: "new", try_count: 0, flags: [], last_tries: [] }], + }), + authorized: (headers) => headers["x-auth-token"] === "11111111-1111-1111-1111-111111111111", + message: (body) => { + const { from, to, subject, html, text, project_id } = json(body); + assert.equal(project_id, "22222222-2222-2222-2222-222222222222"); + return { from: from.email, to: to[0].email, subject, html, text }; + }, + printed: "Scaleway accepted: scaleway-1", + }, + { + script: "plunk", + env: { PLUNK_SECRET_KEY: "sk_test" }, + mock: () => record(nock("https://next-api.useplunk.com").post("/v1/send"), 200, { + success: true, + data: { emails: [{ contact: { id: "contact-1", email: recipient }, email: "plunk-1" }], timestamp: "2026-01-01T00:00:00.000Z" }, + }), + authorized: (headers) => headers.authorization === "Bearer sk_test", + // Plunk takes no plain-text part: it derives one from the HTML. + message: (body) => { + const { from, to, subject, body: html } = json(body); + return { from, to, subject, html }; + }, + printed: "Plunk accepted: plunk-1", + }, + { + script: "lettermint", + env: { LETTERMINT_PROJECT_TOKEN: "lm_test" }, + mock: () => record(nock("https://api.lettermint.co").post("/v1/send"), 202, { message_id: "lettermint-1", status: "pending" }), + authorized: (headers) => headers["x-lettermint-token"] === "lm_test", + message: (body) => { + const { from, to, subject, html, text } = json(body); + return { from, to: to[0], subject, html, text }; + }, + printed: "Lettermint accepted: lettermint-1", + }, + { + script: "brevo", + env: { BREVO_API_KEY: "test-key" }, + mock: () => record(nock("https://api.brevo.com").post("/v3/smtp/email"), 201, { messageId: "" }), + authorized: (headers) => headers["api-key"] === "test-key", + message: (body) => { + const { sender: from, to, subject, htmlContent, textContent } = json(body); + return { from: from.email, to: to[0].email, subject, html: htmlContent, text: textContent }; + }, + printed: "Brevo accepted: ", + }, + { + script: "inbound", + env: { INBOUND_API_KEY: "test-key" }, + mock: () => record(nock("https://inbound.new").post("/api/e2/emails"), 200, { id: "inbound-1" }), + authorized: (headers) => headers.authorization === "Bearer test-key", + message: (body) => { + const { from, to, subject, html, text } = json(body); + return { from, to, subject, html, text }; + }, + printed: "Inbound accepted: inbound-1", + }, + { + script: "eusend", + env: { EUSEND_API_KEY: "eu_test_key" }, + mock: () => record(nock("https://api.eusend.dev").post("/emails"), 200, { id: "eusend-1" }), + authorized: (headers) => headers.authorization === "Bearer eu_test_key", + message: (body) => { + const { from, to, subject, html, text } = json(body); + return { from, to, subject, html, text }; + }, + printed: "eusend accepted: eusend-1", + }, +]; + +test("each provider script sends the rendered HTML and plain text", async (t) => { + const { subject, html, text } = renderInvoiceEmail(); + nock.disableNetConnect(); + try { + for (const provider of providers) { + await t.test(provider.script, async (st) => { + Object.assign(process.env, { EMAIL_FROM: sender, EMAIL_TO: recipient }, provider.env); + const sent = provider.mock(); + const info = st.mock.method(console, "info", () => {}); + await import(`./providers/${provider.script}`); + assert.deepEqual(nock.pendingMocks(), []); + assert.equal(sent.length, 1); + assert.ok(provider.authorized(sent[0].headers), "the request carries the credential"); + const message = provider.message(sent[0].body); + const expected: SentMessage = { from: sender, to: recipient, subject, html, text }; + if (!("text" in message)) delete expected.text; + assert.deepEqual(message, expected); + assert.equal(info.mock.calls.map((call) => call.arguments.join(" ")).join("\n"), provider.printed); + }); + } + } finally { + nock.cleanAll(); + nock.enableNetConnect(); + } +}); diff --git a/examples/i18n/.gitignore b/examples/i18n/.gitignore new file mode 100644 index 00000000..ea1472ec --- /dev/null +++ b/examples/i18n/.gitignore @@ -0,0 +1 @@ +output/ diff --git a/examples/i18n/README.md b/examples/i18n/README.md new file mode 100644 index 00000000..cfa43ca8 --- /dev/null +++ b/examples/i18n/README.md @@ -0,0 +1,25 @@ +# Translated React emails: react-intl, react-i18next, and next-intl + +One shipping notification rendered with `@unlayer/react-elements` in English and Arabic (right-to-left), once with each of three i18n libraries. The [i18n guide](../../packages/react/docs/i18n.md) walks through the code. Commands run from the repository root with Node.js 20+ and pnpm 9. + +```bash +pnpm install +pnpm build +pnpm --filter @unlayer/i18n-example test +pnpm --filter @unlayer/i18n-example render +``` + +`render` writes `output/shipped.en.html`, `output/shipped.ar.html`, and the plain text of each, using the react-intl version. + +## Files + +- `messages/en.json`, `messages/ar.json`: the messages, in ICU syntax, shared by all three libraries. +- `src/locales.ts`: loads a locale's messages (before rendering) and maps it to a text direction. +- `src/react-intl.tsx`: `createIntl()` + `RawIntlProvider`, `useIntl()` in the template. +- `src/react-i18next.tsx`: an initialized i18next instance per locale, `I18nextProvider`, `useTranslation()`. +- `src/next-intl.tsx`: `createTranslator()`, which needs no Next.js request. +- `src/i18n.test.ts`: renders both locales with each library and checks the subject, the translated text in the HTML and plain text, `lang`, `dir`, and the text alignment; and that rendering before i18next has loaded its messages fails. + +```bash +pnpm --filter @unlayer/i18n-example typecheck +``` diff --git a/examples/i18n/messages/ar.json b/examples/i18n/messages/ar.json new file mode 100644 index 00000000..aef017df --- /dev/null +++ b/examples/i18n/messages/ar.json @@ -0,0 +1,9 @@ +{ + "shipped": { + "subject": "تم شحن طلبك {orderId}", + "preview": "طلبك في الطريق إليك.", + "heading": "تم شحن طلبك", + "greeting": "مرحبًا {name}، طلبك {orderId} في الطريق إليك.", + "cta": "تتبّع شحنتك" + } +} diff --git a/examples/i18n/messages/en.json b/examples/i18n/messages/en.json new file mode 100644 index 00000000..1806a5c7 --- /dev/null +++ b/examples/i18n/messages/en.json @@ -0,0 +1,9 @@ +{ + "shipped": { + "subject": "Order {orderId} has shipped", + "preview": "Your order is on its way.", + "heading": "Your order has shipped", + "greeting": "Hi {name}, order {orderId} is on its way.", + "cta": "Track your package" + } +} diff --git a/examples/i18n/package.json b/examples/i18n/package.json new file mode 100644 index 00000000..46878b0c --- /dev/null +++ b/examples/i18n/package.json @@ -0,0 +1,28 @@ +{ + "name": "@unlayer/i18n-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "render": "tsx src/render.ts", + "typecheck": "tsc --noEmit", + "test": "tsx --test src/i18n.test.ts" + }, + "dependencies": { + "@unlayer/react-elements": "workspace:*", + "i18next": "26.4.4", + "next": "16.3.8", + "next-intl": "4.14.9", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-i18next": "17.0.16", + "react-intl": "12.1.4" + }, + "devDependencies": { + "@types/node": "24.9.1", + "@types/react": "18.3.26", + "@types/react-dom": "18.3.7", + "tsx": "4.21.0", + "typescript": "5.9.3" + } +} diff --git a/examples/i18n/src/i18n.test.ts b/examples/i18n/src/i18n.test.ts new file mode 100644 index 00000000..332d4df5 --- /dev/null +++ b/examples/i18n/src/i18n.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import i18next from "i18next"; +import { loadMessages, sampleOrder, type Locale } from "./locales"; +import * as withNextIntl from "./next-intl"; +import { createI18n, renderShippedEmail as renderWithI18next } from "./react-i18next"; +import * as withReactIntl from "./react-intl"; + +const expected: Record = { + en: { + dir: "ltr", + subject: "Order A-1042 has shipped", + heading: "Your order has shipped", + greeting: "Hi Ada, order A-1042 is on its way.", + cta: "Track your package", + preview: "Your order is on its way.", + }, + ar: { + dir: "rtl", + subject: "تم شحن طلبك A-1042", + heading: "تم شحن طلبك", + greeting: "مرحبًا Ada، طلبك A-1042 في الطريق إليك.", + cta: "تتبّع شحنتك", + preview: "طلبك في الطريق إليك.", + }, +}; + +const renderers: Record Promise<{ subject: string; html: string; text: string }>> = { + "react-intl": async (locale) => withReactIntl.renderShippedEmail(locale, await loadMessages(locale), sampleOrder), + "react-i18next": async (locale) => renderWithI18next(await createI18n(locale, await loadMessages(locale)), sampleOrder), + "next-intl": async (locale) => withNextIntl.renderShippedEmail(locale, await loadMessages(locale), sampleOrder), +}; + +for (const [library, render] of Object.entries(renderers)) { + test(`${library} renders the email in English and Arabic`, async () => { + for (const locale of ["en", "ar"] as const) { + const want = expected[locale]; + const { subject, html, text } = await render(locale); + const htmlTag = html.match(/]*>/)?.[0] ?? ""; + assert.match(htmlTag, new RegExp(` lang="${locale}"`)); + assert.match(htmlTag, new RegExp(` dir="${want.dir}"`)); + assert.equal(subject, want.subject); + for (const phrase of [want.heading, want.greeting, want.cta, want.preview]) { + assert.ok(html.includes(phrase), `${locale} HTML contains "${phrase}"`); + } + assert.ok(text.includes(want.greeting), `${locale} plain text contains the greeting`); + // The heading and paragraph start on the reading side: right in Arabic, left in English. + const aligned = html.match(/text-align: (left|right)/g) ?? []; + assert.deepEqual(aligned, [`text-align: ${want.dir === "rtl" ? "right" : "left"}`, `text-align: ${want.dir === "rtl" ? "right" : "left"}`]); + } + }); +} + +test("rendering before i18next has loaded the messages fails instead of sending an empty email", (t) => { + // react-i18next warns that useTranslation suspended; Elements then refuses to render. + t.mock.method(console, "warn", () => {}); + const notLoaded = i18next.createInstance(); + assert.throws(() => renderWithI18next(notLoaded, sampleOrder), /async and suspending templates need/); +}); diff --git a/examples/i18n/src/locales.ts b/examples/i18n/src/locales.ts new file mode 100644 index 00000000..79e77ddc --- /dev/null +++ b/examples/i18n/src/locales.ts @@ -0,0 +1,33 @@ +import { readFile } from "node:fs/promises"; + +export const locales = ["en", "ar"] as const; +export type Locale = (typeof locales)[number]; + +/** One file per locale in messages/, nested by email, with ICU placeholders: "Hi {name}". */ +export interface Messages { + shipped: Record<"subject" | "preview" | "heading" | "greeting" | "cta", string>; +} + +/** Elements renders synchronously, so load a locale's messages before rendering. */ +export async function loadMessages(locale: Locale): Promise { + return JSON.parse(await readFile(new URL(`../messages/${locale}.json`, import.meta.url), "utf8")); +} + +const RTL_LANGUAGES = new Set(["ar", "fa", "he", "ur"]); + +/** The value for the root's `textDirection` prop. */ +export function directionOf(locale: string): "ltr" | "rtl" { + return RTL_LANGUAGES.has(new Intl.Locale(locale).language) ? "rtl" : "ltr"; +} + +export interface Order { + id: string; + customer: string; + trackingUrl: string; +} + +export const sampleOrder: Order = { + id: "A-1042", + customer: "Ada", + trackingUrl: "https://example.com/orders/A-1042", +}; diff --git a/examples/i18n/src/next-intl.tsx b/examples/i18n/src/next-intl.tsx new file mode 100644 index 00000000..2be1114d --- /dev/null +++ b/examples/i18n/src/next-intl.tsx @@ -0,0 +1,34 @@ +import { + Button, Column, ColumnLayouts, Email, Heading, Paragraph, Row, + renderToHtml, renderToPlainText, +} from "@unlayer/react-elements"; +import { createTranslator } from "next-intl"; +import { directionOf, type Locale, type Messages, type Order } from "./locales"; + +type ShippedTranslator = ReturnType>; + +function shippedEmail(t: ShippedTranslator, locale: Locale, order: Order) { + const direction = directionOf(locale); + return ( + + + + {t("heading")} + {t("greeting", { name: order.customer, orderId: order.id })} + + + + + ); +} + +export function renderShippedEmail(locale: Locale, messages: Messages, order: Order) { + // createTranslator needs no request: it works in scripts, job workers and Next.js server code alike. + const t = createTranslator({ locale, messages, namespace: "shipped" }); + const email = shippedEmail(t, locale, order); + return { + subject: t("subject", { orderId: order.id }), + html: renderToHtml(email), + text: renderToPlainText(email), + }; +} diff --git a/examples/i18n/src/react-i18next.tsx b/examples/i18n/src/react-i18next.tsx new file mode 100644 index 00000000..b28bb91f --- /dev/null +++ b/examples/i18n/src/react-i18next.tsx @@ -0,0 +1,53 @@ +import { + Button, Column, ColumnLayouts, Email, Heading, Paragraph, Row, + renderToHtml, renderToPlainText, +} from "@unlayer/react-elements"; +import i18next, { type i18n } from "i18next"; +import { I18nextProvider, useTranslation } from "react-i18next"; +import type { Locale, Messages, Order } from "./locales"; + +/** An i18next instance per locale, initialized before anything renders with it. */ +export async function createI18n(locale: Locale, messages: Messages): Promise { + const instance = i18next.createInstance(); + await instance.init({ + lng: locale, + resources: { [locale]: { translation: messages } }, + interpolation: { + // The shared message files use {name}; i18next's default is {{name}}. + prefix: "{", + suffix: "}", + // Elements escapes text children already. + escapeValue: false, + }, + }); + return instance; +} + +function ShippedEmail({ order }: { order: Order }) { + const { t, i18n } = useTranslation(); + const direction = i18n.dir(); + return ( + + + + {t("shipped.heading")} + {t("shipped.greeting", { name: order.customer, orderId: order.id })} + + + + + ); +} + +export function renderShippedEmail(i18n: i18n, order: Order) { + const email = ( + + + + ); + return { + subject: i18n.t("shipped.subject", { orderId: order.id }), + html: renderToHtml(email), + text: renderToPlainText(email), + }; +} diff --git a/examples/i18n/src/react-intl.tsx b/examples/i18n/src/react-intl.tsx new file mode 100644 index 00000000..84f4c556 --- /dev/null +++ b/examples/i18n/src/react-intl.tsx @@ -0,0 +1,45 @@ +import { + Button, Column, ColumnLayouts, Email, Heading, Paragraph, Row, + renderToHtml, renderToPlainText, +} from "@unlayer/react-elements"; +import { createIntl, RawIntlProvider, useIntl } from "react-intl"; +import { directionOf, type Locale, type Messages, type Order } from "./locales"; + +function ShippedEmail({ order }: { order: Order }) { + const intl = useIntl(); + const values = { name: order.customer, orderId: order.id }; + const direction = directionOf(intl.locale); + return ( + + + + {intl.formatMessage({ id: "shipped.heading" })} + {intl.formatMessage({ id: "shipped.greeting" }, values)} + + + + + ); +} + +export function renderShippedEmail(locale: Locale, messages: Messages, order: Order) { + // react-intl looks messages up by flat ids: { "shipped.heading": "…" }. + const flat = Object.fromEntries( + Object.entries(messages.shipped).map(([key, message]) => [`shipped.${key}`, message]), + ); + const intl = createIntl({ locale, messages: flat }); + const email = ( + + + + ); + return { + subject: intl.formatMessage({ id: "shipped.subject" }, { orderId: order.id }), + html: renderToHtml(email), + text: renderToPlainText(email), + }; +} diff --git a/examples/i18n/src/render.ts b/examples/i18n/src/render.ts new file mode 100644 index 00000000..cd6584f5 --- /dev/null +++ b/examples/i18n/src/render.ts @@ -0,0 +1,11 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { loadMessages, locales, sampleOrder } from "./locales"; +import { renderShippedEmail } from "./react-intl"; + +await mkdir("output", { recursive: true }); +for (const locale of locales) { + const { subject, html, text } = renderShippedEmail(locale, await loadMessages(locale), sampleOrder); + await writeFile(`output/shipped.${locale}.html`, html); + await writeFile(`output/shipped.${locale}.txt`, text); + console.info(`${locale}: ${subject} -> output/shipped.${locale}.html`); +} diff --git a/examples/i18n/tsconfig.json b/examples/i18n/tsconfig.json new file mode 100644 index 00000000..2e1f3423 --- /dev/null +++ b/examples/i18n/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "types": ["node", "react", "react-dom"] + }, + "include": ["src"] +} diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 916e015c..11cab86b 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,7 @@ # @unlayer/react-elements +Upgrading? See [upgrading](./docs/upgrading.md): what changed in 0.3 and in 0.2, and what to check. + ## Unreleased ### New diff --git a/packages/react/README.md b/packages/react/README.md index a9d7308a..65d1bfa3 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -69,8 +69,10 @@ Agents can also preview Elements code without installing anything through the [E ## Task guides and runnable examples - [Transactional email in React](https://github.com/unlayer/elements/blob/main/packages/react/docs/transactional-email.md): render HTML and plain text, then send a receipt. -- [Resend, AWS SES, Postmark, SendGrid, and Nodemailer](https://github.com/unlayer/elements/blob/main/packages/react/docs/email-providers.md): complete provider integrations. +- [Resend, AWS SES, Postmark, SendGrid, Nodemailer, Mailgun, Brevo, and more](https://github.com/unlayer/elements/blob/main/packages/react/docs/email-providers.md): complete integrations for fourteen email providers. - [One React template to email, web, and PDF](https://github.com/unlayer/elements/blob/main/packages/react/docs/one-template-email-web-pdf.md): shared invoice content with output-specific roots. +- [Translations with react-intl, react-i18next, and next-intl](https://github.com/unlayer/elements/blob/main/packages/react/docs/i18n.md): load messages before rendering, and right-to-left emails. +- [Monorepos with npm, pnpm, Yarn, and Bun](https://github.com/unlayer/elements/blob/main/packages/react/docs/monorepo.md): a shared emails package that apps import, and one copy of React. - [React invoice PDF with Playwright](https://github.com/unlayer/elements/blob/main/packages/react/docs/react-to-pdf.md): generate actual PDF bytes from document HTML. - [React to Unlayer visual editing](https://github.com/unlayer/elements/blob/main/packages/react/docs/visual-editing.md): `renderToJson()`, editor loading, export, and supported tree constraints. - [Tailwind CSS in Elements](https://github.com/unlayer/elements/blob/main/packages/react/docs/tailwind.md): `className` with Tailwind classes on any component, as React Email's ``, kept as props in the HTML and the design. diff --git a/packages/react/docs/README.md b/packages/react/docs/README.md index 74623b33..3f8cdc69 100644 --- a/packages/react/docs/README.md +++ b/packages/react/docs/README.md @@ -11,8 +11,10 @@ npm install @unlayer/react-elements react react-dom | Problem | Guide | Result | |---------|-------|--------| | Send a React transactional email, receipt, or order confirmation | [Transactional email in React](./transactional-email.md) | Complete HTML, plain text, and provider-ready message | -| Deliver Elements HTML with an email provider | [Resend, AWS SES, Postmark, SendGrid, and Nodemailer](./email-providers.md) | Five runnable server-side integrations | +| Deliver Elements HTML with an email provider | [Resend, AWS SES, Postmark, SendGrid, Nodemailer, Mailgun, Azure, MailerSend, Scaleway, Plunk, Lettermint, Brevo, Inbound, and eusend](./email-providers.md) | Fourteen runnable server-side integrations | | Share one React template across email, web, and PDF | [One React invoice, three outputs](./one-template-email-web-pdf.md) | Email HTML, responsive page, print HTML, and real PDF | +| Translate an email and support right-to-left languages | [i18n with react-intl, react-i18next, and next-intl](./i18n.md) | English and Arabic renders with `lang`, `dir`, and alignment checked | +| Share templates across apps in a workspace | [Monorepos with npm, pnpm, Yarn, and Bun](./monorepo.md) | One emails package that apps import, and a single copy of React | | Generate an invoice PDF using Playwright | [React to PDF with Playwright](./react-to-pdf.md) | A PDF file created by headless Chromium | | Make AI-generated React content visually editable | [React to Unlayer design JSON](./visual-editing.md) | `renderToJson()` → `loadDesign()` → edited JSON and HTML | | Turn a template made in the Unlayer editor into React code | [Unlayer designs as Elements code](./codegen.md) | `designToElements(design)` → a `.tsx` component that renders back to the same design | @@ -22,6 +24,9 @@ npm install @unlayer/react-elements react react-dom | Use a web font | [Web fonts in Elements](./fonts.md) | `` in the head, the template's font, Outlook's fallback | | Render templates that load data (async components, Suspense) | [Async templates and readable HTML](./async.md) | `await renderToHtml()` from `@unlayer/react-elements/async`, with `pretty` | | Move React Email templates to Elements | [Migrate from React Email](./migrate-from-react-email.md) | `npx @unlayer/migrate`: converted templates, checked against the originals | +| Move MJML templates to Elements | [Migrate from MJML](./migrate-from-mjml.md) | `npx @unlayer/migrate --from mjml`: converted templates, checked against MJML's own HTML | +| Move Beefree templates to Elements | [Migrate from Beefree](./migrate-from-beefree.md) | `npx @unlayer/migrate --from beefree`: converted templates, checked against Beefree's HTML export | +| Upgrade from Elements 0.2.x or 0.1.x | [Upgrade Elements](./upgrading.md) | What changed in 0.3 and in 0.2, and what to check; see also the [changelog](../CHANGELOG.md) | | Open React Email templates in the Unlayer editor | [React Email in the editor](../../../examples/react-email-in-editor) | `npx @unlayer/migrate --design` → `loadDesign()` | | Try Elements or preview a snippet without installing | [Elements Playground](https://examples.unlayer.com/examples/elements-playground/) | Live email, page, or document preview, HTML and design JSON export, and the Unlayer builder | @@ -44,4 +49,4 @@ Elements supplies components and renderers. Your email provider handles delivery - Read the [visual editing constraints](./visual-editing.md#constraints) before promising React-to-editor round trips. Edited JSON doesn't change the JSX it came from: convert it to new Elements code with `designToElements()` from `@unlayer/react-elements/codegen` ([guide](./codegen.md)). - To preview code without installing, open the [Elements Playground](https://examples.unlayer.com/examples/elements-playground/) with the source in the URL hash: `#code=`, optionally with `&mode=email|page|document`. The playground takes plain JSX with no imports. In browsers with WebMCP, call its `render_elements` tool to get the HTML. -The [runnable example](../../../examples/content-workflows) backs these guides. The [React API reference](../README.md) covers component props; [Next.js App Router](../../../examples/nextjs-app-router) demonstrates framework integration. +The [runnable example](../../../examples/content-workflows) and the [i18n example](../../../examples/i18n) back these guides. Coding agents can load the [`unlayer-elements` skill](https://github.com/unlayer/unlayer-skills) (`npx skills add unlayer/unlayer-skills`), a short set of rules for writing correct templates. The [React API reference](../README.md) covers component props; [Next.js App Router](../../../examples/nextjs-app-router) demonstrates framework integration. diff --git a/packages/react/docs/email-providers.md b/packages/react/docs/email-providers.md index 85e7792e..9f5727bc 100644 --- a/packages/react/docs/email-providers.md +++ b/packages/react/docs/email-providers.md @@ -1,6 +1,27 @@ -# Send React email with Resend, AWS SES, Postmark, SendGrid, or Nodemailer - -Use these integrations to deliver HTML and plain text rendered by `@unlayer/react-elements`. Each provider receives the same [transactional invoice message](./transactional-email.md#create-the-provider-ready-message). Pass the rendered `html` string, including its head styles, to the provider; the provider does not need to render the React tree again. +# Send React email with Resend, AWS SES, Postmark, SendGrid, Nodemailer, Mailgun, Brevo, and more + +Use these integrations to deliver HTML and plain text rendered by `@unlayer/react-elements`. Each of the fourteen providers below receives the same [transactional invoice message](./transactional-email.md#create-the-provider-ready-message). Pass the rendered `html` string, including its head styles, to the provider; the provider does not need to render the React tree again. + +## Providers + +| Provider | Install | Credentials | Command | +|----------|---------|-------------|---------| +| [Resend](#resend) | `npm install resend` | `RESEND_API_KEY` | `send:resend` | +| [AWS SES v2](#aws-ses-v2) | `npm install @aws-sdk/client-sesv2` | `AWS_REGION` and the AWS credential chain | `send:ses` | +| [Postmark](#postmark) | `npm install postmark` | `POSTMARK_SERVER_TOKEN` | `send:postmark` | +| [SendGrid](#sendgrid) | `npm install @sendgrid/mail` | `SENDGRID_API_KEY` | `send:sendgrid` | +| [Nodemailer / SMTP](#nodemailer--smtp) | `npm install nodemailer` | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD` | `send:nodemailer` | +| [Mailgun](#mailgun) | `npm install mailgun.js` | `MAILGUN_API_KEY`, `MAILGUN_DOMAIN`, optional `MAILGUN_URL` | `send:mailgun` | +| [Azure Communication Services Email](#azure-communication-services-email) | `npm install @azure/communication-email` | `AZURE_COMMUNICATION_CONNECTION_STRING` | `send:azure` | +| [MailerSend](#mailersend) | `npm install mailersend` | `MAILERSEND_API_KEY` | `send:mailersend` | +| [Scaleway Transactional Email](#scaleway-transactional-email) | `npm install @scaleway/sdk-tem @scaleway/sdk-client` | `SCW_ACCESS_KEY`, `SCW_SECRET_KEY`, `SCW_DEFAULT_PROJECT_ID` | `send:scaleway` | +| [Plunk](#plunk) | none (REST API with `fetch`) | `PLUNK_SECRET_KEY` | `send:plunk` | +| [Lettermint](#lettermint) | `npm install lettermint` | `LETTERMINT_PROJECT_TOKEN` | `send:lettermint` | +| [Brevo](#brevo) | `npm install @getbrevo/brevo` | `BREVO_API_KEY` | `send:brevo` | +| [Inbound](#inbound) | `npm install inboundemail` | `INBOUND_API_KEY` | `send:inbound` | +| [eusend](#eusend) | `npm install @eusend_dev/sdk` | `EUSEND_API_KEY` | `send:eusend` | + +Every provider gets the HTML document and its plain-text alternative, except Plunk, whose API takes HTML only and derives the text part itself. The example pins each SDK it added to an exact version (see [package.json](../../../examples/content-workflows/package.json)); the snippets were checked against those versions' type definitions. ## Shared setup @@ -21,7 +42,13 @@ export EMAIL_TO='your-test-inbox@example.com' Replace both addresses. Set the provider-specific variables below in the process environment or your deployment's secret manager. The scripts do not load `.env` files automatically. No credentials belong in browser code. For standalone use, install `@unlayer/react-elements react react-dom`, add `tsx` to dev dependencies, set `"type": "module"` and `"jsx": "react-jsx"`, and copy the shared files plus your chosen provider file. Then use `npx tsx src/providers/.ts`. -SDK errors are allowed to fail the process; Resend's returned `error` is explicitly thrown. A successful ID/status means the provider accepted the request, not that the recipient received it. Use provider events for delivery/bounce tracking and application-level deduplication for retries. +SDK errors are allowed to fail the process. Where a provider reports a failure as a return value instead (Resend's and eusend's `error`, a failed Azure send, Plunk's error response), the script throws it. A successful ID/status means the provider accepted the request, not that the recipient received it. Use provider events for delivery/bounce tracking and application-level deduplication for retries. + +The example's tests run the Mailgun, Azure, MailerSend, Scaleway, Plunk, Lettermint, Brevo, Inbound, and eusend scripts as they are, with [nock](https://github.com/nock/nock) answering their HTTP requests: no network and no credentials. Each test checks that the request carries the credential, the sender, the recipient, the subject, the rendered HTML and the plain text (where the API takes one), and that the script prints the ID the provider returns: + +```bash +pnpm --filter @unlayer/content-workflows-example test +``` ## Resend @@ -169,4 +196,262 @@ pnpm --filter @unlayer/content-workflows-example send:nodemailer Expected: `SMTP accepted: `. +## Mailgun + +Install in your own project: `npm install mailgun.js`. The workspace example already declares this dependency. + +Set `MAILGUN_API_KEY` and `MAILGUN_DOMAIN` (your verified sending domain, for example `mg.example.com`). For a domain in Mailgun's EU region, also set `MAILGUN_URL=https://api.eu.mailgun.net`. The client uses the `FormData` built into Node.js 18 and later. [Mailgun Node.js SDK](https://documentation.mailgun.com/docs/mailgun/sdk/nodejs_sdk/). + +```ts +import Mailgun from "mailgun.js"; +import { deliveryMessage, requireEnv } from "../message"; + +// mailgun.js builds a multipart request with the FormData that Node.js 18+ provides. +const client = new Mailgun(FormData).client({ + username: "api", + key: requireEnv("MAILGUN_API_KEY"), + // Domains in Mailgun's EU region use https://api.eu.mailgun.net. + url: process.env.MAILGUN_URL ?? "https://api.mailgun.net", +}); +const { from, to, subject, html, text } = deliveryMessage(); +const result = await client.messages.create(requireEnv("MAILGUN_DOMAIN"), { + from, + to: [to], + subject, + html, + text, +}); +console.info("Mailgun accepted:", result.id); +``` + +```bash +pnpm --filter @unlayer/content-workflows-example send:mailgun +``` + +Expected: `Mailgun accepted: `. + +## Azure Communication Services Email + +Install in your own project: `npm install @azure/communication-email`. The workspace example already declares this dependency. Azure's SDK packages declare Node.js 22 or newer: the example's script and its test run on Node.js 20 too, but use 22 or newer for a project of your own. + +Set `AZURE_COMMUNICATION_CONNECTION_STRING` to the connection string of a Communication Services resource with an email domain connected to it, and use a sender address from that domain (for example `DoNotReply@`) as `EMAIL_FROM`. To use Microsoft Entra ID instead of the access key, pass the endpoint and a credential from `@azure/identity`: `new EmailClient(endpoint, new DefaultAzureCredential())`. `beginSend()` queues the message; `pollUntilDone()` waits until Azure reports the send operation's final status. [Azure email quickstart](https://learn.microsoft.com/en-us/azure/communication-services/quickstarts/email/send-email?pivots=programming-language-javascript). + +```ts +import { EmailClient } from "@azure/communication-email"; +import { deliveryMessage, requireEnv } from "../message"; + +const client = new EmailClient(requireEnv("AZURE_COMMUNICATION_CONNECTION_STRING")); +const { from, to, subject, html, text } = deliveryMessage(); +// beginSend queues the message and returns a poller for the send operation. +const poller = await client.beginSend({ + senderAddress: from, + recipients: { to: [{ address: to }] }, + content: { subject, html, plainText: text }, +}); +const result = await poller.pollUntilDone(); +if (result.status !== "Succeeded") { + throw new Error(`Azure Communication Services: ${result.error?.message ?? result.status}`); +} +console.info("Azure accepted:", result.id); +``` + +```bash +pnpm --filter @unlayer/content-workflows-example send:azure +``` + +Expected: `Azure accepted: `. + +## MailerSend + +Install in your own project: `npm install mailersend`. The workspace example already declares this dependency. + +Set `MAILERSEND_API_KEY` to an API token with email access, and send from a verified domain. MailerSend answers `202 Accepted` with the message ID in the `x-message-id` header. On an HTTP error the SDK rejects with an object holding `statusCode`, `headers`, and `body`, not with an `Error`. [MailerSend Node.js SDK](https://github.com/mailersend/mailersend-nodejs). + +```ts +import { EmailParams, MailerSend, Recipient, Sender } from "mailersend"; +import { deliveryMessage, requireEnv } from "../message"; + +const client = new MailerSend({ apiKey: requireEnv("MAILERSEND_API_KEY") }); +const { from, to, subject, html, text } = deliveryMessage(); +const params = new EmailParams() + .setFrom(new Sender(from)) + .setTo([new Recipient(to)]) + .setSubject(subject) + .setHtml(html) + .setText(text); +const response = await client.email.send(params); +console.info("MailerSend accepted:", response.statusCode, response.headers["x-message-id"]); +``` + +```bash +pnpm --filter @unlayer/content-workflows-example send:mailersend +``` + +Expected: `MailerSend accepted: 202 `. + +## Scaleway Transactional Email + +Install in your own project: `npm install @scaleway/sdk-tem @scaleway/sdk-client`. The workspace example already declares these dependencies. + +Set `SCW_ACCESS_KEY`, `SCW_SECRET_KEY` (an IAM API key allowed to send email), and `SCW_DEFAULT_PROJECT_ID` (the Project that owns your checked domain). Transactional Email runs in the `fr-par` region. The package exports the API as `Temv1alpha1`; the response lists the emails it created. [Scaleway Transactional Email](https://www.scaleway.com/en/docs/transactional-email/), [API reference](https://www.scaleway.com/en/developers/api/transactional-email/). + +```ts +import { createClient } from "@scaleway/sdk-client"; +import { Temv1alpha1 } from "@scaleway/sdk-tem"; +import { deliveryMessage, requireEnv } from "../message"; + +const client = createClient({ + accessKey: requireEnv("SCW_ACCESS_KEY"), + secretKey: requireEnv("SCW_SECRET_KEY"), + defaultProjectId: requireEnv("SCW_DEFAULT_PROJECT_ID"), + // Transactional Email runs in the fr-par region. + defaultRegion: "fr-par", +}); +const tem = new Temv1alpha1.API(client); +const { from, to, subject, html, text } = deliveryMessage(); +const { emails } = await tem.createEmail({ + from: { email: from }, + to: [{ email: to }], + subject, + html, + text, +}); +console.info("Scaleway accepted:", emails.map((email) => email.id).join(", ")); +``` + +```bash +pnpm --filter @unlayer/content-workflows-example send:scaleway +``` + +Expected: `Scaleway accepted: `. + +## Plunk + +No package to install: the `@plunk/node` package on npm is deprecated, so this script calls Plunk's REST API with the `fetch` built into Node.js 18 and later. + +Set `PLUNK_SECRET_KEY` to your project's secret key (`sk_...`; the public `pk_` key can't send) and send from a verified domain. The API takes the HTML as `body` and has no plain-text field: Plunk generates the text part from the HTML. Each send also creates or updates a contact for the recipient. [Plunk send API](https://docs.useplunk.com/api-reference/public-api/sendEmail). + +```ts +import { deliveryMessage, requireEnv } from "../message"; + +// The Plunk npm package is deprecated, so this calls Plunk's REST API directly. +// Plunk derives the plain-text part from `body`; the API has no text field. +type PlunkResponse = + | { success: true; data: { emails: { email: string }[] } } + | { success: false; error: { code: string; message: string } }; + +const { from, to, subject, html } = deliveryMessage(); +const response = await fetch("https://next-api.useplunk.com/v1/send", { + method: "POST", + headers: { + Authorization: `Bearer ${requireEnv("PLUNK_SECRET_KEY")}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ from, to, subject, body: html }), +}); +const result = (await response.json()) as PlunkResponse; +if (!result.success) throw new Error(`Plunk ${response.status}: ${result.error.message}`); +console.info("Plunk accepted:", result.data.emails[0].email); +``` + +```bash +pnpm --filter @unlayer/content-workflows-example send:plunk +``` + +Expected: `Plunk accepted: `. + +## Lettermint + +Install in your own project: `npm install lettermint`. The workspace example already declares this dependency. + +Set `LETTERMINT_PROJECT_TOKEN` to a project sending token (`lm_...`); a team token (`lm_team_...`) can't send. Send from a domain verified in Lettermint. [Lettermint Node.js SDK](https://github.com/lettermint/lettermint-node). + +```ts +import { Lettermint } from "lettermint"; +import { deliveryMessage, requireEnv } from "../message"; + +const client = new Lettermint({ sendingToken: requireEnv("LETTERMINT_PROJECT_TOKEN") }); +const { from, to, subject, html, text } = deliveryMessage(); +const result = await client.emails.send({ from, to: [to], subject, html, text }); +console.info("Lettermint accepted:", result.message_id); +``` + +```bash +pnpm --filter @unlayer/content-workflows-example send:lettermint +``` + +Expected: `Lettermint accepted: `. + +## Brevo + +Install in your own project: `npm install @getbrevo/brevo`. The workspace example already declares this dependency. The snippet uses the `BrevoClient` API that `@getbrevo/brevo` has had since version 4; version 3 and earlier used `TransactionalEmailsApi` instead. + +Set `BREVO_API_KEY` to a Brevo API key and send from a verified sender or domain. [Brevo Node.js SDK](https://github.com/getbrevo/brevo-node), [transactional email guide](https://developers.brevo.com/docs/send-a-transactional-email). + +```ts +import { BrevoClient } from "@getbrevo/brevo"; +import { deliveryMessage, requireEnv } from "../message"; + +const client = new BrevoClient({ apiKey: requireEnv("BREVO_API_KEY") }); +const { from, to, subject, html, text } = deliveryMessage(); +const result = await client.transactionalEmails.sendTransacEmail({ + sender: { email: from }, + to: [{ email: to }], + subject, + htmlContent: html, + textContent: text, +}); +console.info("Brevo accepted:", result.messageId); +``` + +```bash +pnpm --filter @unlayer/content-workflows-example send:brevo +``` + +Expected: `Brevo accepted: `. + +## Inbound + +Install in your own project: `npm install inboundemail`. The workspace example already declares this dependency. The older `@inboundemail/sdk` package is deprecated. + +Set `INBOUND_API_KEY` and send from a domain verified in Inbound. The SDK's `react` option renders React Email components, not Elements; pass the Elements output as `html` and `text`. [Inbound TypeScript SDK](https://github.com/inboundemail/inbound-typescript-sdk), [API docs](https://inbound.new/docs). + +```ts +import Inbound from "inboundemail"; +import { deliveryMessage, requireEnv } from "../message"; + +const client = new Inbound({ apiKey: requireEnv("INBOUND_API_KEY") }); +const { from, to, subject, html, text } = deliveryMessage(); +const result = await client.emails.send({ from, to, subject, html, text }); +console.info("Inbound accepted:", result.id); +``` + +```bash +pnpm --filter @unlayer/content-workflows-example send:inbound +``` + +Expected: `Inbound accepted: `. + +## eusend + +Install in your own project: `npm install @eusend_dev/sdk`. The workspace example already declares this dependency. + +Set `EUSEND_API_KEY`. Keys starting with `eu_test_` exercise the API without delivering; use an `eu_live_` key and a verified domain to deliver. Like Resend, the SDK returns `{ data, error }` instead of throwing. Its `react` option renders React Email components, not Elements; pass the Elements output as `html` and `text`. [eusend Node.js SDK](https://eusend.dev/docs/sdks/nodejs). + +```ts +import { Eusend } from "@eusend_dev/sdk"; +import { deliveryMessage, requireEnv } from "../message"; + +const client = new Eusend(requireEnv("EUSEND_API_KEY")); +const { data, error } = await client.emails.send(deliveryMessage()); +if (error) throw new Error(`eusend: ${error.name} ${error.message}`); +console.info("eusend accepted:", data.id); +``` + +```bash +pnpm --filter @unlayer/content-workflows-example send:eusend +``` + +Expected: `eusend accepted: `. + For PDF attachments, first generate bytes with the [Playwright guide](./react-to-pdf.md), then follow your provider's attachment API and size limits. Design JSON is for the [visual editor](./visual-editing.md), not an email body. diff --git a/packages/react/docs/i18n.md b/packages/react/docs/i18n.md new file mode 100644 index 00000000..6198e64e --- /dev/null +++ b/packages/react/docs/i18n.md @@ -0,0 +1,239 @@ +# Translate React emails with react-intl, react-i18next, or next-intl + +Elements has no translation system of its own: use the i18n library your application already has. This guide renders one shipping notification in English and Arabic with [react-intl](#react-intl), [react-i18next](#react-i18next), and [next-intl](#next-intl). Every snippet is a file in the [i18n example](../../../examples/i18n), and its test renders both locales with each library. + +```bash +pnpm install +pnpm build +pnpm --filter @unlayer/i18n-example test +pnpm --filter @unlayer/i18n-example render +``` + +`render` writes `examples/i18n/output/shipped.en.html` and `shipped.ar.html` (and the plain text of each). Open the Arabic file to see the right-to-left layout. + +## The rules + +1. **Load messages, then render.** `renderToHtml()`, `renderToPlainText()`, and `renderToJson()` are synchronous. Read message files, initialize the i18n instance, and fetch data before you call them. A template that suspends while its messages load (react-i18next's `useTranslation()` does) makes the renderer throw `async and suspending templates need "@unlayer/react-elements/async"'s renderers, or their data loaded first`; a component further down that suspends or is `async` fails the render too, with a `RenderError` naming it (`it is async or suspends, and this renderer is synchronous`), unless you set `onError: "omit"`. Don't `await` translations inside a component. (The [async renderers](./async.md) render templates that load data, but messages loaded first keep the render synchronous, and the email can't go out with keys in it.) +2. **Providers and hooks work.** Wrap the template in the library's provider and call its hook in your components: Elements renders the template once, with React's hooks and context, so context reaches every component, including the one that returns ``. A provider written as a class component (react-intl's ``) works the same way: the root's `lang` and `textDirection` inside it are read. +3. **Give blocks strings or message components.** Call `intl.formatMessage()` or `t()` and pass the string to `Heading`, `Paragraph`, or `Button`, as the templates below do. Their children can also be `` or ``, which render with the template's context, formatting tags included. Props such as `previewText` and `href` take strings only. +4. **Set the language and direction on the root.** `` writes `` and marks each text block `dir="rtl"`. The design JSON keeps `textDirection` but not `lang`; options passed to `renderToHtml()` override both props. +5. **Text follows the direction.** In a right-to-left email, `Heading` and `Paragraph` default to `textAlign="right"`, so Arabic or Hebrew text starts at the right edge. Pass `textAlign` to align a block otherwise. (Elements 0.2 left-aligned them in every direction.) With [Tailwind](./tailwind.md#right-to-left), logical classes follow the direction too: `ps-4` and `text-start` are the right side in a right-to-left email. +6. **Translate the subject and preview text too.** The subject goes to your email provider, not into the HTML; the inbox preview is the root's `previewText`. +7. **Escape once.** Elements escapes text passed as children, so turn off escaping in the i18n library (i18next's `escapeValue: false`) to avoid `&lt;`. Values interpolated into a `Paragraph`'s `html` prop are raw markup: escape them yourself, as the [invoice template](./one-template-email-web-pdf.md#define-the-shared-react-template) does. + +## Messages and direction + +The example keeps one JSON file per locale in [messages/](../../../examples/i18n/messages), in ICU syntax, and all three libraries read the same files: + +```json +{ + "shipped": { + "subject": "تم شحن طلبك {orderId}", + "preview": "طلبك في الطريق إليك.", + "heading": "تم شحن طلبك", + "greeting": "مرحبًا {name}، طلبك {orderId} في الطريق إليك.", + "cta": "تتبّع شحنتك" + } +} +``` + +[src/locales.ts](../../../examples/i18n/src/locales.ts) loads them and maps a locale to its direction: + +```ts +import { readFile } from "node:fs/promises"; + +export const locales = ["en", "ar"] as const; +export type Locale = (typeof locales)[number]; + +/** One file per locale in messages/, nested by email, with ICU placeholders: "Hi {name}". */ +export interface Messages { + shipped: Record<"subject" | "preview" | "heading" | "greeting" | "cta", string>; +} + +/** Elements renders synchronously, so load a locale's messages before rendering. */ +export async function loadMessages(locale: Locale): Promise { + return JSON.parse(await readFile(new URL(`../messages/${locale}.json`, import.meta.url), "utf8")); +} + +const RTL_LANGUAGES = new Set(["ar", "fa", "he", "ur"]); + +/** The value for the root's `textDirection` prop. */ +export function directionOf(locale: string): "ltr" | "rtl" { + return RTL_LANGUAGES.has(new Intl.Locale(locale).language) ? "rtl" : "ltr"; +} + +export interface Order { + id: string; + customer: string; + trackingUrl: string; +} + +export const sampleOrder: Order = { + id: "A-1042", + customer: "Ada", + trackingUrl: "https://example.com/orders/A-1042", +}; +``` + +## react-intl + +`createIntl()` makes the formatter for the subject, and `RawIntlProvider` gives the same one to the template, whose components call `useIntl()`. `` works as well. [src/react-intl.tsx](../../../examples/i18n/src/react-intl.tsx): + +```tsx +import { + Button, Column, ColumnLayouts, Email, Heading, Paragraph, Row, + renderToHtml, renderToPlainText, +} from "@unlayer/react-elements"; +import { createIntl, RawIntlProvider, useIntl } from "react-intl"; +import { directionOf, type Locale, type Messages, type Order } from "./locales"; + +function ShippedEmail({ order }: { order: Order }) { + const intl = useIntl(); + const values = { name: order.customer, orderId: order.id }; + const direction = directionOf(intl.locale); + return ( + + + + {intl.formatMessage({ id: "shipped.heading" })} + {intl.formatMessage({ id: "shipped.greeting" }, values)} + + + + + ); +} + +export function renderShippedEmail(locale: Locale, messages: Messages, order: Order) { + // react-intl looks messages up by flat ids: { "shipped.heading": "…" }. + const flat = Object.fromEntries( + Object.entries(messages.shipped).map(([key, message]) => [`shipped.${key}`, message]), + ); + const intl = createIntl({ locale, messages: flat }); + const email = ( + + + + ); + return { + subject: intl.formatMessage({ id: "shipped.subject" }, { orderId: order.id }), + html: renderToHtml(email), + text: renderToPlainText(email), + }; +} +``` + +## react-i18next + +Create an i18next instance per locale and `await` its `init()`, so `useTranslation()` has its messages when the template renders. `i18n.dir()` gives the direction of the instance's language. [src/react-i18next.tsx](../../../examples/i18n/src/react-i18next.tsx): + +```tsx +import { + Button, Column, ColumnLayouts, Email, Heading, Paragraph, Row, + renderToHtml, renderToPlainText, +} from "@unlayer/react-elements"; +import i18next, { type i18n } from "i18next"; +import { I18nextProvider, useTranslation } from "react-i18next"; +import type { Locale, Messages, Order } from "./locales"; + +/** An i18next instance per locale, initialized before anything renders with it. */ +export async function createI18n(locale: Locale, messages: Messages): Promise { + const instance = i18next.createInstance(); + await instance.init({ + lng: locale, + resources: { [locale]: { translation: messages } }, + interpolation: { + // The shared message files use {name}; i18next's default is {{name}}. + prefix: "{", + suffix: "}", + // Elements escapes text children already. + escapeValue: false, + }, + }); + return instance; +} + +function ShippedEmail({ order }: { order: Order }) { + const { t, i18n } = useTranslation(); + const direction = i18n.dir(); + return ( + + + + {t("shipped.heading")} + {t("shipped.greeting", { name: order.customer, orderId: order.id })} + + + + + ); +} + +export function renderShippedEmail(i18n: i18n, order: Order) { + const email = ( + + + + ); + return { + subject: i18n.t("shipped.subject", { orderId: order.id }), + html: renderToHtml(email), + text: renderToPlainText(email), + }; +} +``` + +Rendering with an instance that hasn't loaded its messages throws instead of sending keys or an empty email; the example's test checks this. Don't work around it with `useSuspense: false`: the template would render before the messages are there. + +## next-intl + +next-intl's request-scoped APIs need a Next.js request. Outside one (a script, a job worker, a test), use `createTranslator()`, which needs only a locale and messages. next-intl's Server Component entry exports it too, so the same template can run in a Route Handler. The translator is typed by the messages: a missing key is a type error. [src/next-intl.tsx](../../../examples/i18n/src/next-intl.tsx): + +```tsx +import { + Button, Column, ColumnLayouts, Email, Heading, Paragraph, Row, + renderToHtml, renderToPlainText, +} from "@unlayer/react-elements"; +import { createTranslator } from "next-intl"; +import { directionOf, type Locale, type Messages, type Order } from "./locales"; + +type ShippedTranslator = ReturnType>; + +function shippedEmail(t: ShippedTranslator, locale: Locale, order: Order) { + const direction = directionOf(locale); + return ( + + + + {t("heading")} + {t("greeting", { name: order.customer, orderId: order.id })} + + + + + ); +} + +export function renderShippedEmail(locale: Locale, messages: Messages, order: Order) { + // createTranslator needs no request: it works in scripts, job workers and Next.js server code alike. + const t = createTranslator({ locale, messages, namespace: "shipped" }); + const email = shippedEmail(t, locale, order); + return { + subject: t("subject", { orderId: order.id }), + html: renderToHtml(email), + text: renderToPlainText(email), + }; +} +``` + +Inside a Next.js request (a Route Handler, a Server Action, or a Server Component), you can instead `await getTranslations({ locale, namespace: "shipped" })` from `next-intl/server`, with your next-intl request configuration, and pass the result to the template. Await it before rendering. Outside Next.js server code (in a plain Node.js script, for example) it throws an error saying `getTranslations` is not supported in Client Components. The example's test covers `createTranslator()` only. next-intl lists `next` as a peer dependency, so install it even where you only use `createTranslator()`. + +## Related guides + +- [Transactional email](./transactional-email.md): render HTML and plain text for a provider. +- [Email providers](./email-providers.md): send the translated subject, HTML, and text. +- [Visual editing](./visual-editing.md): when opening a right-to-left design in the editor, also call `unlayer.setTextDirection("rtl")`. diff --git a/packages/react/docs/migrate-from-beefree.md b/packages/react/docs/migrate-from-beefree.md new file mode 100644 index 00000000..935f4b1d --- /dev/null +++ b/packages/react/docs/migrate-from-beefree.md @@ -0,0 +1,90 @@ +# Migrate from Beefree + +Move Beefree templates to Unlayer Elements to keep them as React components in your code, render them as email HTML, and open them in the Unlayer visual editor. One command converts each template's JSON and, with Beefree's HTML export beside it, checks the result against that export. + +```bash +npx @unlayer/migrate templates --from beefree # converts and checks your Beefree templates (their JSON), and writes nothing +npx @unlayer/migrate templates --from beefree --write # then writes templates/.tsx beside each one +npm install @unlayer/react-elements@^0.3.0 +``` + +It works as an [MJML migration](./migrate-from-mjml.md) does: the same options (`--write`, `--out`, `--design`, `--report`, `--strict`), output and exit codes, with Node.js 20.6 or later. The JSON files stay where they are, and each migrated template is a new `.tsx` beside its JSON (or in `--out `). + +## The JSON it reads + +Beefree's classic "full" JSON, the one its editor saves, in versions `0.0.1` and `2.0.0` (`page.template.version`). The template can be saved as Beefree gives it (`{ "page": … }`), as the Template Catalog API returns it (`{ "json_data": { "page": … } }`, also with `json_data` as a JSON string), or as Beefree's `ITemplateJson` (`{ "data": { "json": { "page": … } } }`). + +Every `.json` file in the folders given is read. One that isn't a Beefree template is skipped, with what it is: a rows collection, a single row, a popup's layout configuration, or a file with no `page` at all (a `tsconfig.json`). Beefree's Simple Schema (`template.rows`) is skipped too: convert it to full JSON first, with Beefree's Content Services API, or open and save it in Beefree. + +A template of another version fails, and says so, rather than being read as well as possible: + +```text +✗ templates/old.json: Unknown Beefree template version "3.0.0": only 0.0.1 and 2.0.0 are supported. Open and save it in Beefree, or report it with the file +``` + +## Put the HTML export beside it + +A Beefree template renders in Beefree, so the check needs Beefree's own HTML for it. Export each template's HTML from Beefree and save it beside the JSON, with the same name: `welcome.json` and `welcome.html`. The migration is then checked against it, as MJML templates are checked against MJML's compile: every word, link, image and `alt` text must be the same, on desktop and on a phone, with no clear text turned too faint to read (`text now hard to read`), and how it looks is listed. + +A template without its export is converted and written, but **unverified**: neither `✓` nor `✗`, but `?`, and not counted as a failure, so the run can end with `0`. With `--strict`, an unverified template fails, and isn't written unless you pass `--force`. + +```text +✗ templates/old.json: Unknown Beefree template version "3.0.0": only 0.0.1 and 2.0.0 are supported. Open and save it in Beefree, or report it with the file +? templates/promo.json: unverified (no exported HTML: put Beefree's HTML export of it beside it as promo.html to check it), 100% editable, 1 kind of difference (social icon alt text is “ icon”) +✓ templates/sale.json: 100% editable, 6 kinds of difference (Beefree folds this menu into a hamburger button on phones; Unlayer menus have none, so phones show its items; its columns are aligned to the middle in Beefree; Unlayer columns sit at the top; Beefree stacks its columns in reverse order on phones; Unlayer stacks them in order; social icon alt text is “ icon”; menu toggle glyph left out; menu links shown on phones, where Beefree folds them into a hamburger button) + looks different: the original's phone styles apply up to 660px, Elements' up to 480px: between 481px and 660px the migration shows the desktop version, with what shows only on desktop +- templates/rows.json: skipped: not a Beefree template: it's a list (a rows collection, such as a custom rows file), not a template with a `page` + +3 templates: 1 migrated and checked (1 looks different: see above), 1 unverified (nothing to check against: see above), 1 failed. 1 other file skipped (see above). Nothing written: pass --write or --out to write the migrated templates. +``` + +## Declared differences + +Two content changes are the editor's own way of writing a block. The converter declares each one, naming exactly what it changes, and the check expects those and nothing else. They're listed as differences, not as `✗`; any other change in the content still fails the template, and `--strict` fails declared differences as it does any other. + +- **Social icons' `alt` text.** The Social block writes each icon's `alt` as "Facebook icon", where Beefree's says "Facebook". The report lists each one: `social icon alt text is “ icon” (the editor's Social block writes it this way) (×6): alt "Facebook" → "Facebook icon", …`. +- **Phone hamburger menus.** On phones, Beefree can fold a menu into a hamburger button. Unlayer menus have no such button, so its `☰` and `✕` glyphs (in Beefree's newer exports, the button) are left out, and the menu's links show on phones. The report lists `menu toggle glyph left out (Unlayer menus have no hamburger button on phones)` and `menu links shown on phones, where Beefree folds them into a hamburger button (Unlayer menus have none)`. + +## What maps to what + +| Beefree | Elements | +|---|---| +| The body | ``: the message width (500px when the template sets none) as `contentWidth`, the background, text and link colors, the font, the preheader as `previewText`, and right-to-left text. | +| A row and its columns | A `Row` with its columns' shares of Beefree's 12-column grid as `cells`, and its backgrounds and padding. Columns that don't stack on phones keep `noStackMobile`. | +| `text`, `paragraph`, `list` | `Paragraph`, with its HTML. | +| `heading` | `Heading`. | +| `image`, `button` | `Image`, `Button`. | +| `divider` | `Divider`. A divider Beefree doesn't draw (a transparent or 0px line, used as space) is an invisible one. | +| `spacer` | An invisible `Divider` as tall as the space, or, with a background color, a `Divider` that thick in that color. | +| `social` | `Social`, each icon keeping Beefree's image and link. | +| `menu` | `Menu`. | +| `html` | An `Html` block, as it was. | +| `video` | `Video`: a YouTube or Vimeo thumbnail linking to it. | +| `icons` | `Image` when it's one picture without text; otherwise kept as HTML. | +| `addon` | `Image` when it holds an image; otherwise kept as HTML. | +| `merge-content`, `form`, `table`, `carousel`, and modules the converter doesn't know | Kept as HTML. | + +Modules kept as HTML render as their HTML, but the editor can't edit what's inside. The run counts them beside the check (`96% editable, 1 block kept as HTML (icons)`), and `--strict` fails a template with any. + +What's approximated is listed per template, with where it is (`Row 5`, `Row 2, column 1, module 3 (image)`): column vertical alignment (Unlayer columns sit at the top), columns stacked in reverse order on phones, the space between columns with backgrounds, a row's border and rounded corners, an image's rounded corners, a row narrower than the message, a background image sized to cover its row (email HTML shows it at its own size), and phone styles with no Unlayer setting. + +## After migrating + +The migrated templates import `@unlayer/react-elements`. Render them, send them with any [email provider](./email-providers.md), or open them in the editor: + +```tsx +import { renderToHtml, renderToJson, renderToPlainText } from "@unlayer/react-elements"; +import Welcome from "./templates/welcome"; + +const html = renderToHtml(); +const text = renderToPlainText(); +unlayer.loadDesign(renderToJson()); // the design --design writes +``` + +The migrated component takes no props: the template's sample content is written in, and merge tags stay as text. Replace them with props as described in [Migrate from MJML](./migrate-from-mjml.md#steps) (step 7). + +## Agent rules + +- Run `npx @unlayer/migrate --from beefree --report migration.md` first. For each template the run marks `?`, ask the user for Beefree's HTML export and save it beside the JSON as `.html`, then run again. Don't report an unverified template as checked. +- Exit code `2` means a template failed: report the file and the reason. An unknown version means the JSON isn't one this converter reads; don't edit its version number to get past it. +- List the differences the report gives for the user, including the declared ones. diff --git a/packages/react/docs/migrate-from-mjml.md b/packages/react/docs/migrate-from-mjml.md new file mode 100644 index 00000000..c26c6cde --- /dev/null +++ b/packages/react/docs/migrate-from-mjml.md @@ -0,0 +1,224 @@ +# Migrate from MJML + +Move MJML templates to Unlayer Elements to write them as React components: data comes in as props, loops and conditions are JavaScript, shared parts are imports, and the same template renders as an email, a web page or a print document, and opens in the Unlayer visual editor. One command converts them and checks each one against the HTML MJML compiles it to. + +```bash +npx @unlayer/migrate emails --from mjml # converts and checks your .mjml templates, and writes nothing +npx @unlayer/migrate emails --from mjml --write # then writes emails/.tsx beside each one +npm install @unlayer/react-elements@^0.3.0 +``` + +Run it from your project folder, with Node.js 20.6 or later. It reads MJML 4 and 5 templates. The `.mjml` files stay as they are: each migrated template is a new file beside its `.mjml` (or in `--out `), so you can compare the two before you delete the MJML. MJML and Beefree templates need `@unlayer/migrate` 0.2 or later, which renders with `@unlayer/react-elements` 0.3: an older Elements installed where the templates are stops the run before anything is written, with the command that installs a supported one. + +## What a run prints + +A folder with three templates and a partial they include: + +```text +✓ emails/launch.mjml: 50% editable, 2 blocks kept as HTML (mj-hero, mj-carousel) +✗ emails/receipt.mjml: it's MJML 3 (it wraps its content in ), which MJML 5 doesn't read: upgrade it first with npx mjml-migrate , then migrate the output +✓ emails/welcome.mjml: 100% editable +- emails/partials/brand.mjml: skipped: not an MJML document (no root): a partial, converted where a template includes it + +3 templates: 2 migrated and checked, 1 failed. 2 blocks kept as HTML (not editable as blocks). 1 other file skipped (see above). Nothing written: pass --write or --out to write the migrated templates. +``` + +- `✓`: converted, with the same content as MJML's HTML. The line says how much is editable, which blocks were kept as HTML, and the kinds of difference (`column vertical alignment not kept`, `columns stack on narrower screens`). What looks different is listed below it, under `looks different:`. +- `✗`: failed. The template couldn't be converted (MJML 3, no ``, a file that isn't UTF-8), an `mj-include` couldn't be read, its content differs from MJML's or clear text in it turned too faint to read, or, with `--strict`, it has any difference. Without `--strict`, a template whose content differs is still written and flagged: that's a converter bug, please report it. +- `-`: skipped. A file without an `` root is a partial: it's read where a template includes it, and never written. +- `?` doesn't appear in an MJML run. It marks a template with nothing to check against, and MJML compiles every template. See [Beefree templates](./migrate-from-beefree.md), where it does. + +Exit codes: + +- `0`: every template converted with the same content. What looks different is listed, and doesn't fail the run. +- `1`: a usage error, no MJML templates found, a file already at a destination (see `--overwrite`), or an `@unlayer/react-elements` outside `^0.3.0` where the templates are. +- `2`: a template failed (with `--strict`, any difference fails it). + +## Steps + +1. **Check.** Run the command without `--write`. Nothing is written. Read what's kept as HTML and what differs, per template, in a report: `--report migration.md`. +2. **Write.** Add `--write` to write `.tsx` beside each `.mjml` file, or `--out ` to write them in another folder. Add `--design` for each template's design JSON, ready for the editor. +3. **Install Elements.** The migrated templates import `@unlayer/react-elements`. When it isn't installed where they're written, the run ends with the command to install it. +4. **Render where you compiled MJML.** Where your code called `mjml2html(source).html`, render the component: + + ```tsx + import { renderToHtml, renderToPlainText } from "@unlayer/react-elements"; + import Welcome from "./emails/welcome"; + + const html = renderToHtml(, { title: "Welcome to Acme" }); + const text = renderToPlainText(); + ``` + + `renderToHtml()` returns the complete document, and `renderToPlainText()` the text part MJML doesn't make. Send both with any [email provider](./email-providers.md). The `` is in the migrated file's header comment: pass it as the `title` option. +5. **Look at it.** A small script writes the HTML to a file you open in a browser, beside MJML's own output (`npx mjml emails/welcome.mjml -o welcome.mjml.html`). Run it with `npx tsx preview.ts`: + + ```ts + import { writeFileSync } from "node:fs"; + import { createElement } from "react"; + import { renderToHtml, renderToPlainText } from "@unlayer/react-elements"; + import Welcome from "./emails/welcome"; + + const email = createElement(Welcome); + writeFileSync("welcome.html", renderToHtml(email, { title: "Welcome to Acme" })); + writeFileSync("welcome.txt", renderToPlainText(email)); + ``` + + Compare the two at a desktop width and at a phone width: that's where the differences the run listed show. +6. **Open it in the visual editor** (optional). `renderToJson()` gives the design the Unlayer editor loads, the same design `--design` writes: + + ```ts + import { renderToJson } from "@unlayer/react-elements"; + + unlayer.loadDesign(renderToJson()); + ``` + + See [visual editing](./visual-editing.md) for what round-trips. A web font's stylesheet URL is in the design, so the editor loads it; to offer it in the editor's font picker too, register it in the editor's `fonts.customFonts` (see [web fonts](./fonts.md)). +7. **Make it a template.** The migrated component takes no props: the sample data is written in, and merge tags (`{{name}}`, `*|FNAME|*`) stay as text, as MJML had them. If your email service fills them in, leave them. Otherwise replace them with props, as you would have with a template language: `{{name}}` becomes `{name}`, an `{{#each}}` loop becomes `items.map(...)`, and a condition becomes `&&` or `? :`. The converter writes text in Paragraph's `html` prop, which is markup: escape a value you put there yourself, or move plain text to the children, which are escaped for you. Give the component `PreviewProps` with sample data, for your own previews and tests: + + ```tsx + Welcome.PreviewProps = { name: "Ada" }; + ``` + +## Options + +| Option | | +|---|---| +| `--write` | Write `.tsx` beside each `.mjml` file. | +| `--out ` | Write them in `` instead, with the layout under the folder given (`emails/welcome.mjml` → `/welcome.tsx`). | +| `--design` | Also write `.design.json`: the design the migrated code renders (`renderToJson()`), for the editor's `loadDesign()`. | +| `--report ` | Write the report: JSON for a `.json` file, Markdown for any other. For each template: what was kept as HTML, what the migration did (the includes it followed), and each difference with what it's about. | +| `--strict` | Fail a template with any difference, including a block kept as HTML and a [declared difference](#what-the-check-compares-against), and don't write it. | +| `--force` | With `--strict`, write the templates it fails anyway. The run still exits with `2`. | +| `--overwrite` | Replace a `.tsx` or design JSON an earlier run wrote, while nobody has edited it, or one git can restore. Without it, a file already at a destination stops the run. | + +The same command migrates React Email templates (the default, without `--from`); see the [`@unlayer/migrate` README](../../migrate#options) for every option. + +## What becomes editable blocks + +The converter resolves MJML's attribute cascade as MJML does: `mj-attributes` with `mj-all` and per-tag defaults, `mj-class`, MJML's own defaults, then the attributes written on the tag. Each block gets the result as its props, so a shared color shows on every block that has it. Then: + +| MJML | Elements | +|---|---| +| `mj-body`, `mj-preview`, `mj-font` | ``'s `contentWidth`, `backgroundColor`, `previewText` and `fontFamily`, with a `` for a web font. A page MJML leaves without a background is white. | +| `mj-section`, `mj-column` | A `Row` and its `Column`s, as wide as MJML makes them. The section's padding, border and background, and the space narrow columns leave, go on the Row and its outer Columns, so content sits where it does in MJML. | +| `mj-text` | `Paragraph`, or `Heading` when it holds one `

`–`

`, with its padding (`containerPadding`), colors, font and alignment. | +| `mj-button`, `mj-image`, `mj-divider` | `Button`, `Image`, `Divider`. | +| `mj-wrapper` | Its sections as Rows of their own, with its background and padding around them. Its border, rounded corners and background image aren't kept. | +| `mj-group` | A Row whose columns stay side by side on phones (`noStackMobile`). | +| `mj-social` | `Social`, with MJML's links. MJML's built-in icons become the editor's icons for the networks; an icon with its own `src` keeps its image. | +| `mj-navbar` | `Menu`. Its links' letter case and underline aren't kept. | +| `mj-spacer` | An empty `Divider` as tall as the space, which the editor shows and resizes. | +| `mj-table` | `Table`, when it's a plain grid (text in each cell, no spans, no colors or borders of its own). | + +What doesn't carry over exactly is listed, per template, as a difference: column `vertical-align` (email columns sit at the top), a section's background image and rounded corners, a section's or `mj-group`'s `direction="rtl"` in a left-to-right email, an image's `border-radius`, `height` and `fluid-on-mobile`, a button's `height`, `mj-style` rules for other screens or states (`@media`, `:hover`), and `mj-html-attributes`. `mj-style` rules that apply at any width are written on the text they style. + +On phones, MJML stacks columns below 480px (or its `mj-breakpoint`), and Elements below the content width plus 20px: 620px for a 600px email. Between the two, MJML shows the columns side by side, where Elements stacks them. The run lists it as `columns stack on narrower screens`. + +Elements lays out every Row's columns in the email's direction: with `textDirection="rtl"`, right to left, the first on the right. MJML lays out each section's and group's columns by its own `direction`, left to right unless it says `rtl`, whatever `` says. So `` becomes a right-to-left email only when no section or group shows two or more columns left to right; otherwise the email stays left to right, with its columns where MJML shows them, and the run lists it as `mjml dir rtl not kept`. A section or group with `direction="rtl"` in a left-to-right email shows its columns in order. Phones stack columns in order in both. + +## What stays MJML's HTML + +Some components have no block the editor can edit. They're kept as the HTML MJML compiles them to, with the CSS they need, in an `Html` block: they render as they did in MJML, but the editor can't edit what's inside. The run counts them as not editable (`50% editable, 2 blocks kept as HTML (mj-hero, mj-carousel)`), and `--strict` fails a template with any: + +- `mj-hero`: content over a background image. +- `mj-carousel` and `mj-accordion`: interactive. +- `mj-navbar` with a hamburger (its icons show only on phones), and `mj-social` with labels, without links, or with an icon for `xing`, `google` or `web` (networks the editor has no icon for). +- `mj-table` that isn't a plain grid. +- `mj-raw`, which is HTML already: an `Html` block with its markup as written. Markup no reader sees (Outlook's conditional comments) is left out. +- Components MJML puts where an editor block can't go (an `mj-section` in a column), and tags the converter doesn't know. + +To make one editable, rebuild it with blocks: see [tuning by hand](#tuning-by-hand). + +## `mj-include` + +Includes are followed as MJML follows them: relative to the file that includes them, with `.mjml` added when the path has no extension and the file isn't there. A partial's head goes into the template's head and its body where the include is; a CSS file becomes an `mj-style`, and an HTML file an `mj-raw`. The report lists each include it followed. + +An include outside the folder you gave, or one that doesn't exist, isn't read. Since the content it holds would be missing from both sides of the check, the template fails: + +```text +✗ emails/welcome.mjml: check failed: couldn't read mj-include ../shared/brand.mjml (outside emails, not read), so its content isn't all there +``` + +Give the folder that holds both (`npx @unlayer/migrate . --from mjml`, or the templates' common parent) so the include is inside it. + +## MJML 3 + +An MJML 3 template (everything inside ``) fails, since MJML 4 dropped that structure and MJML 5 doesn't read it. Upgrade it with MJML's migration tool, as the run says, then migrate the upgraded file: + +```bash +npx mjml-migrate mjml3/receipt.mjml emails/receipt.mjml +npx @unlayer/migrate emails --from mjml +``` + +## What the check compares against + +MJML's own compile. The converter compiles each original with the `mjml` version `@unlayer/migrate` pins (5.4.1, not the one in your project), with its includes resolved as above, renders the migrated code with Elements, and compares the two HTML documents as a React Email migration does. Nothing is fetched: the check reads the HTML, it doesn't load images or fonts. + +- **Content** must be the same: every word, link, image and image `alt` text, in the same order, on desktop and on a phone, with no new words, and every block reaching the visual editor. Anything the converter can't map is kept as MJML's HTML, so a content difference is a bug in the converter. +- **How it looks** (a text style, where a line sits, a phone width other than Elements' 480px) is listed under `looks different:`, to fix in the editor or the code. One fails the template: text that reads clearly in MJML's HTML and is too faint to read in the migration. The words are there, but a reader can't make them out: the run says `text now hard to read`, with the colors (`"Read more" is #f7dd72 on #ffffff`). +- **Declared differences** are content changes that are the editor's own way of writing a block. The Social block writes each icon's `alt` text as "Facebook icon", and MJML's built-in icons (white glyphs MJML draws on a colored square) become the editor's icon for the network. The converter declares each one, naming the image it changes, and the check expects exactly those: they're listed as differences (`social icon alt text is “ icon”`), not as `✗`. Any other content change still fails the template, and `--strict` fails declared differences as it does any other. + +## Tuning by hand + +The converter writes each block with the props it resolved. To tidy a migrated template, or to rebuild a block that was kept as HTML, these are the props MJML's attributes correspond to: + +| MJML | Elements | Notes | +|------|----------|-------| +| `` | `Email` `lang`, `textDirection` | `dir="rtl"` becomes `textDirection="rtl"` only when no section or group shows two or more columns left to right, since `textDirection="rtl"` lays out every Row's columns right to left and MJML's `dir` doesn't. Otherwise the email stays left to right, and the run lists it (`mjml dir rtl not kept`). `textDirection="rtl"` also marks each text block right-to-left, and text blocks start on the right. | +| `` | `Row`, with a [column layout](../README.md#column-layouts) | `ColumnLayouts.TwoEqual`, or `cells={[3, 7]}` for `width="30%"` and `width="70%"` columns. | +| `` | `Column` | `padding`, `background-color`, `border-radius` → `padding`, `backgroundColor`, `borderRadius`; `border` → a `border` object with each side's width, style and color. | +| `` | `Paragraph` or `Heading` | `font-size`, `color`, `font-weight`, `font-family`, `align`, `line-height` → `fontSize`, `color`, `fontWeight`, `fontFamily`, `textAlign`, `lineHeight`; `padding` → `containerPadding`. | +| `` | `Button` | `inner-padding` → `padding`, `padding` → `containerPadding`, `align` → `textAlign`, `border` → a `border` object. | +| `` | `Image` | `alt` → `alt`, `href` → `action`, `align` → `textAlign`, `width` → `width`. | +| `` | `Divider` | `border-width`, `border-style`, `border-color` → `borderTopWidth`, `borderTopStyle`, `borderTopColor`. | +| `` | `Social` | `icons={[{ name: "Facebook", url }]}`, `icon-size` → `iconSize`. | +| `` | `Menu` | `items={[{ text, href }]}`, link `color` → `linkColor`. | +| `` | `Row` with `backgroundImage` | `backgroundImage={{ url, fullWidth: false, size: "cover", position: "center" }}`; the Column's padding sets the height. | +| ``, `` | `Image`; `Heading` and `Paragraph` | No interactive blocks: show the first slide as an `Image`, and accordion items expanded. | + +A Row has two backgrounds. `backgroundColor` spans the full width of the email, like a `full-width` section's, and the Row's `padding` sits inside it. `columnsBackgroundColor` covers the content width, like a section's own `background-color`, and the Row's `padding` sits outside it. So for a boxed section with a background, put the inside spacing on the Columns: + +```tsx + + {/* blocks */} + +``` + +The converter writes the attributes `mj-attributes` and `mj-class` gave a block on that block. To share them again, spread an object, or make a small component: + +```tsx +import { Paragraph, type ParagraphProps } from "@unlayer/react-elements"; + +// +const muted = { fontSize: "13px", color: "#71717a" }; +Acme Inc., 1 Example Street; + +// or a component, with your defaults and any prop overridden +export function Muted(props: ParagraphProps) { + return ; +} +``` + +For what the run lists as a difference: + +| MJML | How to approximate it | +|------|-----------------------| +| `` | Email columns sit at the top. Even out the columns' content, or add `padding` to the shorter one. | +| `` | Phone settings apply at 480px and below. In emails, columns stack below the content width plus 20px; `noStackMobile` keeps a Row's columns side by side. | +| `` width on phones | A fixed `width` is a share of the column, so the image gets smaller on phones, where MJML keeps it. Add `mobile={{ width: "120px" }}` to keep it; for `fluid-on-mobile`, `mobile={{ autoWidth: true }}` fills the column, up to the image's own width. | +| `` media queries for phones | Phone settings: `mobile={{ fontSize: "26px", textAlign: "center" }}` on a block, `mobile={{ padding: "16px" }}` on a Column or Row (`containerPadding` on a block), and `hideOnMobile` or `hideOnDesktop` on a block or Row. | +| `` border or rounded corners | A border or radius around several Rows doesn't carry over. With one Column, give the Column `backgroundColor` and `borderRadius`. | +| ``, `` | In a left-to-right email, columns render in source order, left to right: write them in the order you want. In a right-to-left email (`textDirection="rtl"` on the root), every Row's columns render right to left, the first on the right. Phones stack them in source order in both. | +| `` | No collapsing menu. Use a `Menu` with `hideOnMobile` and a second one with `layout="vertical"` and `hideOnDesktop`. | +| `` cell styles, `colspan`, images in cells | `Table` applies one style to every header cell and one to every body cell. For a layout table (an order line with a thumbnail), use a Row with Columns. | +| Hover styles | Emails don't carry hover CSS. A Button's `hoverBackgroundColor` and `hoverColor` apply to web pages (`Page`). | +| ``, `css-class`, other CSS | Use `renderToHtmlParts()`, which returns the `head` and `body`, and add your own `