` attributes and a default slot.
64 |
65 | ### Row
66 |
67 | - Groups columns horizontally.
68 | - Default slot for `Column` components, plus `` attributes.
69 |
70 | ### Column
71 |
72 | - Wraps content inside a table cell.
73 | - Accepts all `| ` attributes, including `align`, `width`, and `style`.
74 |
75 | ## Typography
76 |
77 | ### Heading
78 |
79 | - `as? = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'` — element to render.
80 | - Margin shorthands: `m`, `mx`, `my`, `mt`, `mr`, `mb`, `ml`.
81 | - Default slot for heading text; forwards remaining `` attributes.
82 |
83 | ### Text
84 |
85 | - `as? = string` — element type, defaults to ` `.
86 | - Default slot for body copy and all ` ` attributes.
87 | - Merges `style` with default font size/line height.
88 |
89 | ## Links and buttons
90 |
91 | ### Link
92 |
93 | - `href: string` (required).
94 | - `target? = '_blank'`.
95 | - Default slot for link text; all other anchor attributes pass through.
96 |
97 | ### Button
98 |
99 | - `href? = '#'`.
100 | - `target? = '_blank'`.
101 | - `pX? = 0`, `pY? = 0` — horizontal and vertical padding in pixels.
102 | - Default slot for button content; forwards remaining `` attributes.
103 |
104 | ## Media and dividers
105 |
106 | ### Img
107 |
108 | - `src: string`, `alt: string`, `width: string`, `height: string` (all required).
109 | - Forwards additional ` ` attributes and merges custom styles.
110 |
111 | ### Hr
112 |
113 | - Accepts all ` ` attributes and merges any styles you provide.
114 |
--------------------------------------------------------------------------------
/src/lib/render/utils/css/__snapshots__/sanitize-non-inlinable-rules.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`sanitizeNonInlinableRules() > handles CSS nesting in hover pseudo styles 1`] = `
4 | "/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */
5 | @layer theme, base, components, utilities;
6 | @layer theme {
7 | :root, :host {
8 | --color-sky-600: oklch(58.8% 0.158 241.966) !important;
9 | --color-gray-100: oklch(96.7% 0.003 264.542) !important;
10 | }
11 | }
12 | @layer utilities {
13 | .hover_text-sky-600 {
14 | &:hover {
15 | @media (hover: hover) {
16 | color: var(--color-sky-600) !important;
17 | }
18 | }
19 | }
20 | .sm_focus_outline-none {
21 | @media (width >= 40rem) {
22 | &:focus {
23 | --tw-outline-style: none !important;
24 | outline-style: none !important;
25 | }
26 | }
27 | }
28 | .md_hover_bg-gray-100 {
29 | @media (width >= 48rem) {
30 | &:hover {
31 | @media (hover: hover) {
32 | background-color: var(--color-gray-100) !important;
33 | }
34 | }
35 | }
36 | }
37 | .lg_focus_underline {
38 | @media (width >= 64rem) {
39 | &:focus {
40 | text-decoration-line: underline !important;
41 | }
42 | }
43 | }
44 | }
45 | "
46 | `;
47 |
48 | exports[`sanitizeNonInlinableRules() > inlines rules that can be inlined 1`] = `
49 | "/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */
50 | @layer theme, base, components, utilities;
51 | @layer theme {
52 | :root, :host {
53 | --color-red-300: oklch(80.8% 0.114 19.571) !important;
54 | --color-gray-900: oklch(21% 0.034 264.665) !important;
55 | --text-lg: 1.125rem !important;
56 | --text-lg--line-height: calc(1.75 / 1.125) !important;
57 | }
58 | }
59 | @layer utilities {
60 | .bg-gray-900 {
61 | background-color: var(--color-gray-900);
62 | }
63 | .text-lg {
64 | font-size: var(--text-lg);
65 | line-height: var(--tw-leading, var(--text-lg--line-height));
66 | }
67 | .text-red-300 {
68 | color: var(--color-red-300);
69 | }
70 | }
71 | "
72 | `;
73 |
74 | exports[`sanitizeNonInlinableRules() > supports basic media query rules 1`] = `
75 | "/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */
76 | @layer theme, base, components, utilities;
77 | @layer theme {
78 | :root, :host {
79 | --spacing: 0.25rem !important;
80 | --container-lg: 32rem !important;
81 | --radius-lg: 0.5rem !important;
82 | }
83 | }
84 | @layer utilities {
85 | .sm_mx-auto {
86 | @media (width >= 40rem) {
87 | margin-inline: auto !important;
88 | }
89 | }
90 | .sm_max-w-lg {
91 | @media (width >= 40rem) {
92 | max-width: var(--container-lg) !important;
93 | }
94 | }
95 | .sm_rounded-lg {
96 | @media (width >= 40rem) {
97 | border-radius: var(--radius-lg) !important;
98 | }
99 | }
100 | .md_px-10 {
101 | @media (width >= 48rem) {
102 | padding-inline: calc(var(--spacing) * 10) !important;
103 | }
104 | }
105 | .md_py-12 {
106 | @media (width >= 48rem) {
107 | padding-block: calc(var(--spacing) * 12) !important;
108 | }
109 | }
110 | }
111 | "
112 | `;
113 |
--------------------------------------------------------------------------------
/src/routes/docs/migrating-to-v1/+page.md:
--------------------------------------------------------------------------------
1 | # Migrating to v1
2 |
3 | If you are using v0.x.x of Better Svelte Email, you can migrate to v1.x.x by following these steps.
4 |
5 | ## Update your dependencies
6 |
7 | ```bash
8 | npm install better-svelte-email@latest
9 | ```
10 |
11 | If you are using tailwind:
12 |
13 | ```bash
14 | npm install tailwindcss@latest
15 | ```
16 |
17 | ## New Renderer class
18 |
19 | Previously, you would add the `betterSvelteEmailPreprocessor` to your `svelte.config.js` file, this is now deprecated, you will need to remove it to make the new version work.
20 |
21 | To render email in v1, you will need to replace the `render` function from `svelte/server` with the new `Renderer` class.
22 |
23 | ```typescript
24 | import { Renderer } from 'better-svelte-email/render';
25 |
26 | const { renderer } = new Renderer();
27 |
28 | const html = await render(emailComponent);
29 | ```
30 |
31 | ## Tailwind classes
32 |
33 | Since v1 now uses tailwindcss v4, you will need to update all your tailwind classes to the new syntax. See the [tailwindcss v4 migration guide](https://tailwindcss.com/docs/upgrade-guide) for more information.
34 |
35 | Another change is that you can now use inline classes in your email templates:
36 |
37 | ```svelte
38 | Hello
39 | World
40 | ```
41 |
42 | This works too with the `style` prop.
43 |
44 | ## Plain text rendering
45 |
46 | The `renderAsPlainText` function has been deprecated in favor of the `toPlainText` function from the `better-svelte-email/render` package.
47 |
48 | ```typescript
49 | import { toPlainText } from 'better-svelte-email/render';
50 |
51 | const plainText = toPlainText(html);
52 | ```
53 |
54 | ## Email Preview
55 |
56 | In v1, the preview system now uses a route-based approach instead. For that to work, you will need to move your `+page.svelte` and `+page.server.ts` files from the `src/routes/email-preview` directory to the `src/routes/email-preview/[...email]` directory.
57 |
58 | ```
59 | // Before
60 | src/routes/email-preview
61 | ├── +page.svelte
62 | └── +page.server.ts
63 | // After
64 | src/routes/email-preview/[...email]
65 | ├── +page.svelte
66 | └── +page.server.ts
67 | ```
68 |
69 | In the `+page.server.ts` file, the `createEmail` action is now a function that needs to be called like the `sendEmail` function:
70 |
71 | ```typescript
72 | // src/routes/email-preview/[...email]/+page.server.ts
73 | export const actions = {
74 | // Before
75 | ...createEmail,
76 | ...sendEmail()
77 | // After
78 | ...createEmail(),
79 | ...sendEmail()
80 | };
81 | ```
82 |
83 | You will also need to update the `+page.svelte` like so:
84 |
85 | ```svelte
86 |
87 |
91 |
92 |
93 | ```
94 |
95 | ### Using a tailwind config
96 |
97 | To use a custom tailwind config, you will need to pass an instance of the `Renderer` class to the `createEmail` and `sendEmail` functions:
98 |
99 | ```typescript
100 | // src/routes/email-preview/[...email]/+page.server.ts
101 | import { Renderer } from 'better-svelte-email/render';
102 |
103 | const renderer = new Renderer(tailwindConfig);
104 |
105 | export const actions = {
106 | ...createEmail({ renderer }),
107 | ...sendEmail({ renderer })
108 | };
109 | ```
110 |
111 | See the [email preview documentation](./email-preview) for more information.
112 |
--------------------------------------------------------------------------------
/src/lib/components/__tests__/__fixtures__/test-email.svelte:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | Responsive Heading
37 |
38 |
39 | Multiple utilities text
40 |
41 |
42 | Bold red text with margin
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | Column 1
52 | Left column content
53 |
54 |
55 | Column 2
56 | Right column content
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
71 |
72 |
73 |
74 | Visit our website
75 |
76 |
77 |
78 |
85 |
86 |
87 |
93 |
94 |
95 |
96 | Nested Container
97 |
98 | This tests complex spacing, borders, and shadows
99 |
100 |
101 |
102 |
103 | Custom arbitrary values
104 |
105 |
106 | Text with empty style attribute
107 |
108 |
109 | Pure inline styles
110 |
111 |
112 |
113 |
--------------------------------------------------------------------------------
/src/routes/docs/getting-started/+page.md:
--------------------------------------------------------------------------------
1 | # Getting started
2 |
3 | Welcome to **Better Svelte Email**! This guide walks you through installation, configuration, and building your first email.
4 |
5 | ## Requirements
6 |
7 | - `svelte >= v5.14.3`
8 | - `tailwindcss >= v4`
9 |
10 | ## Installation
11 |
12 | Install the package:
13 |
14 | ```bash
15 | npm install better-svelte-email
16 | ```
17 |
18 | ## Write your first email
19 |
20 | Create a new file at `src/lib/emails/welcome.svelte`. This example uses tailwind, but bare css works too.
21 |
22 | ```svelte
23 |
24 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | Welcome {name}!
47 |
48 | Better Svelte Email converts Svelte components into email-safe HTML.
49 |
50 |
51 |
59 |
67 |
68 |
69 |
70 |
71 |
72 | ```
73 |
74 | ## Render and send it
75 |
76 | Render the email using the `Renderer` class and send it using your preferred email provider (resend in this example).
77 |
78 | ```typescript
79 | // src/routes/api/send-email/+server.ts
80 | import Renderer from 'better-svelte-email/render';
81 | import { Resend } from 'resend';
82 | import { env } from '$env/dynamic/private';
83 | import WelcomeEmail from '$lib/emails/welcome.svelte';
84 |
85 | const { render } = new Renderer();
86 | const resend = new Resend(env.PRIVATE_RESEND_API_KEY);
87 |
88 | export async function POST({ request }) {
89 | const { name, email } = await request.json();
90 |
91 | const html = await render(WelcomeEmail, { props: { name } });
92 |
93 | // Send email using your preferred service (Resend, SendGrid, etc.)
94 | await resend.emails.send({
95 | from: 'onboarding@resend.dev',
96 | to: email,
97 | subject: 'Welcome!',
98 | html
99 | });
100 |
101 | return new Response('Email sent');
102 | }
103 | ```
104 |
105 | ## Plain text version
106 |
107 | You can also render the email as plain text using the `toPlainText` function for better accessibility.
108 |
109 | ```typescript
110 | import { toPlainText } from 'better-svelte-email/render';
111 |
112 | const plainText = toPlainText(html);
113 |
114 | await resend.emails.send({
115 | from: 'onboarding@resend.dev',
116 | to: email,
117 | subject: 'Welcome!',
118 | html,
119 | // Add the plain text version to the email
120 | text: plainText
121 | });
122 | ```
123 |
124 | ## Further configuration
125 |
126 | ### Tailwind configuration
127 |
128 | You can pass a Tailwind configuration object to the `Renderer` class.
129 |
130 | ```js
131 | const tailwindConfig = {
132 | theme: { extend: { colors: { brand: '#FF3E00' } } }
133 | };
134 | const { render } = new Renderer({ tailwindConfig });
135 | ```
136 |
137 | ## Preview your emails
138 |
139 | Better Svelte Email provides an `EmailPreview` component that you can use to preview your emails in the browser.
140 | See the [Email Preview](./email-preview) section for a guide on how to use it.
141 |
--------------------------------------------------------------------------------
/src/lib/emails/demo-email.svelte:
--------------------------------------------------------------------------------
1 |
49 |
50 |
51 | |
52 |
53 |
54 |
55 |
56 |
57 |
58 | Better Svelte Email
59 |
60 |
61 | Welcome, {name}! 👋
62 |
63 |
64 | Create beautiful, responsive emails using Svelte components and Tailwind CSS. Better
65 | Svelte Email transforms your familiar workflow into email-safe HTML with full Tailwind
66 | support.
67 |
68 |
69 |
70 |
71 |
72 | Why Better Svelte Email?
73 |
74 | {#each features as feature, index}
75 |
76 |
77 | ✓
78 |
79 |
80 |
81 | {feature.title}
82 |
83 |
84 | {feature.description}
85 |
86 |
87 |
88 | {/each}
89 |
90 |
91 |
92 |
100 |
101 |
102 |
103 |
104 |
105 |
106 | Check out the
107 |
111 | documentation
112 |
113 | to learn more,
or star on
114 |
118 | GitHub
119 |
120 | to support the project.
121 |
122 |
123 | Built with ❤️ by Konixy
126 |
127 |
128 |
129 |
130 |
131 |
--------------------------------------------------------------------------------
/src/lib/emails/examples/vercel-invite-user.svelte:
--------------------------------------------------------------------------------
1 |
50 |
51 |
52 |
53 |
54 |
55 |
58 |
59 |
66 |
67 |
68 | Join {teamName} on Vercel
69 |
70 |
71 | Hello {username},
72 |
73 |
74 | {invitedByUsername} (
78 | {invitedByEmail}
79 | ) has invited you to the {teamName} team on
80 | Vercel.
81 |
82 |
83 |
84 |
85 |
92 |
93 |
94 |
100 |
101 |
102 |
109 |
110 |
111 |
112 |
113 |
119 |
120 |
121 | or copy and paste this URL into your browser:
122 |
123 | {inviteLink}
124 |
125 |
126 |
127 |
128 | This invitation was intended for
129 | {username}. This invite was sent from
130 | {inviteFromIp}
131 | located in
132 | {inviteFromLocation}. If you were not expecting this
133 | invitation, you can ignore this email. If you are concerned about your account's safety,
134 | please reply to this email to get in touch with us.
135 |
136 |
137 |
138 |
139 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "better-svelte-email",
3 | "description": "Svelte email renderer with Tailwind support",
4 | "version": "1.1.0",
5 | "author": "Konixy",
6 | "repository": {
7 | "type": "git",
8 | "url": "git+https://github.com/Konixy/better-svelte-email.git"
9 | },
10 | "homepage": "https://better-svelte-email.konixy.fr",
11 | "peerDependencies": {
12 | "svelte": ">5.43.9",
13 | "@sveltejs/kit": ">=2"
14 | },
15 | "peerDependenciesMeta": {
16 | "@sveltejs/kit": {
17 | "optional": true
18 | }
19 | },
20 | "dependencies": {
21 | "html-to-text": "^9.0.5",
22 | "parse5": "^8.0.0",
23 | "postcss": "^8.5.6",
24 | "postcss-value-parser": "^4.2.0",
25 | "tailwindcss": "^4.1.17"
26 | },
27 | "optionalDependencies": {
28 | "prettier": "^3.6.2",
29 | "resend": "^6.4.2",
30 | "shiki": "^3.15.0"
31 | },
32 | "devDependencies": {
33 | "@eslint/compat": "^2.0.0",
34 | "@eslint/js": "^9.39.1",
35 | "@sveltejs/adapter-vercel": "^6.1.1",
36 | "@sveltejs/kit": "^2.48.5",
37 | "@sveltejs/package": "^2.5.4",
38 | "@sveltejs/vite-plugin-svelte": "^6.2.1",
39 | "@tailwindcss/vite": "^4.1.17",
40 | "@types/html-to-text": "^9.0.4",
41 | "@types/node": "22",
42 | "@vitest/coverage-v8": "^4.0.14",
43 | "eslint": "^9.39.1",
44 | "eslint-config-prettier": "^10.1.8",
45 | "eslint-plugin-svelte": "^3.13.0",
46 | "globals": "^16.5.0",
47 | "mdsvex": "^0.12.6",
48 | "mode-watcher": "^1.1.0",
49 | "prettier-plugin-svelte": "^3.4.0",
50 | "prettier-plugin-tailwindcss": "^0.7.1",
51 | "publint": "^0.3.15",
52 | "rehype-autolink-headings": "^7.1.0",
53 | "rehype-slug": "^6.0.0",
54 | "svelte": "5.45.2",
55 | "svelte-check": "^4.3.4",
56 | "tailwindcss-motion": "^1.1.1",
57 | "typescript": "^5.9.3",
58 | "typescript-eslint": "^8.47.0",
59 | "vite": "^7.2.2",
60 | "vitest": "^4.0.10"
61 | },
62 | "exports": {
63 | ".": {
64 | "types": "./dist/index.d.ts",
65 | "svelte": "./dist/index.js",
66 | "import": "./dist/index.js",
67 | "default": "./dist/index.js"
68 | },
69 | "./components": {
70 | "types": "./dist/components/index.d.ts",
71 | "import": "./dist/components/index.js",
72 | "default": "./dist/components/index.js"
73 | },
74 | "./components/*": {
75 | "types": "./dist/components/*.svelte.d.ts",
76 | "svelte": "./dist/components/*.svelte"
77 | },
78 | "./preview": {
79 | "types": "./dist/preview/index.d.ts",
80 | "import": "./dist/preview/index.js",
81 | "default": "./dist/preview/index.js"
82 | },
83 | "./preview/EmailPreview.svelte": {
84 | "types": "./dist/preview/EmailPreview.svelte.d.ts",
85 | "svelte": "./dist/preview/EmailPreview.svelte"
86 | },
87 | "./utils": {
88 | "types": "./dist/utils/index.d.ts",
89 | "import": "./dist/utils/index.js",
90 | "default": "./dist/utils/index.js"
91 | },
92 | "./render": {
93 | "types": "./dist/render/index.d.ts",
94 | "import": "./dist/render/index.js",
95 | "default": "./dist/render/index.js"
96 | },
97 | "./package.json": "./package.json"
98 | },
99 | "files": [
100 | "dist",
101 | "!dist/**/*.test.*",
102 | "!dist/**/*.spec.*",
103 | "!dist/**/__fixtures__",
104 | "!dist/emails"
105 | ],
106 | "keywords": [
107 | "svelte",
108 | "svelte5",
109 | "email",
110 | "tailwind",
111 | "tailwindcss",
112 | "inline-styles",
113 | "responsive-email",
114 | "email-templates"
115 | ],
116 | "license": "MIT",
117 | "scripts": {
118 | "dev": "vite dev",
119 | "build": "bun run prepack && vite build",
120 | "preview": "vite preview",
121 | "package": "svelte-package",
122 | "package:watch": "nodemon -x \"bun run package\" -i dist -e ts,svelte",
123 | "prepare": "svelte-kit sync || echo ''",
124 | "prepack": "svelte-kit sync && svelte-package && publint",
125 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
126 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
127 | "format": "prettier --write .",
128 | "lint": "prettier --check . && eslint .",
129 | "test": "vitest run",
130 | "test:watch": "vitest",
131 | "test:ui": "vitest --ui",
132 | "test:coverage": "vitest run --coverage"
133 | },
134 | "sideEffects": [
135 | "**/*.css"
136 | ],
137 | "svelte": "./dist/index.js",
138 | "type": "module",
139 | "types": "./dist/index.d.ts"
140 | }
141 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Better Svelte Email
4 |
5 | Create beautiful emails in Svelte with first-class Tailwind support
6 |
7 |
8 | Website
9 | ·
10 | GitHub
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | ## Usage
26 |
27 | See the [documentation](https://better-svelte-email.konixy.fr/docs) for a complete guide on how to use Better Svelte Email.
28 |
29 | ## Features
30 |
31 | - **Tailwind v4 Support** - Transforms Tailwind classes to inline styles for email clients
32 | - **Built-in Email Preview** - Visual email preview and test sending
33 | - **TypeScript First** - Fully typed with comprehensive type definitions
34 | - **Well Tested** - >90% test coverage with unit and integration tests
35 |
36 | _See [Roadmap](./ROADMAP.md) for future features and planned improvements._
37 |
38 | ## Why?
39 |
40 | Existing Svelte email solutions have significant limitations:
41 |
42 | - **svelte-email** hasn't been updated in over 2 years
43 | - **svelte-email-tailwind** suffers from stability issues and maintaining it is not sustainable anymore
44 |
45 | Better Svelte Email is a complete rewrite of [svelte-email-tailwind](https://github.com/steveninety/svelte-email-tailwind) inspired by [React Email](https://react.email/), providing the rock-solid foundation your email infrastructure needs. It brings the simplicity, reliability, and feature richness of [React Email](https://react.email/) to the Svelte ecosystem.
46 |
47 | ## Minimum Svelte Version
48 |
49 | The minimum supported Svelte version is 5.14.3.
50 | For older versions, you can use [`svelte-email-tailwind`](https://github.com/steveninety/svelte-email-tailwind).
51 |
52 | ## Supported Features
53 |
54 | ### ✅ Supported
55 |
56 | - All tailwindcss v4 utilities
57 | - Custom Tailwind classes (`bg-[#fff]`, `my:[40px]`, ...)
58 | - Dynamic Tailwind classes (`class={someVar}`)
59 | - Responsive breakpoints (`sm:`, `md:`, `lg:`, `xl:`, `2xl:`)
60 | - HTML elements and Svelte components
61 | - Nested components
62 | - All svelte features such as each blocks (`{#each}`) and if blocks (`{#if}`), and more
63 | - Custom Tailwind configurations
64 |
65 | ## Author
66 |
67 | Anatole Dufour ([@Konixy](https://github.com/Konixy))
68 |
69 | ### Author of `svelte-email-tailwind`
70 |
71 | Steven Polak ([@steveninety](https://github.com/steveninety))
72 |
73 | ### Authors of `react-email`
74 |
75 | Bu Kinoshita ([@bukinoshita](https://github.com/bukinoshita))
76 |
77 | Zeno Rocha ([@zenorocha](https://github.com/zenorocha))
78 |
79 | ## Development
80 |
81 | ### Running Tests
82 |
83 | ```bash
84 | bun run test
85 | ```
86 |
87 | All tests must pass before pushing to main. The CI/CD pipeline will automatically run tests on every push and pull request.
88 |
89 | ### Building
90 |
91 | ```bash
92 | bun run build
93 | ```
94 |
95 | ### Contributing
96 |
97 | Contributions are welcome! Please feel free to submit a Pull Request.
98 |
99 | To do so, you'll need to:
100 |
101 | 1. Fork the repository
102 | 2. Create a feature branch (`git checkout -b feat/amazing-feature`)
103 | 3. Make your changes
104 | 4. Run tests (`bun run test`)
105 | 5. Commit your changes using [conventional commits](https://www.conventionalcommits.org/):
106 | - `feat:` - New features
107 | - `fix:` - Bug fixes
108 | - `docs:` - Documentation changes
109 | - `test:` - Test additions/changes
110 | - `chore:` - Maintenance tasks
111 | 6. Push to your branch (`git push origin feat/amazing-feature`)
112 | 7. Open a Pull Request
113 |
114 | ## Acknowledgements
115 |
116 | Many components and logic were inspired by or adapted from [svelte-email-tailwind](https://github.com/steveninety/svelte-email-tailwind) and [react-email](https://react.email/). Huge thanks to the authors and contributors of these projects for their excellent work.
117 |
--------------------------------------------------------------------------------
/src/lib/render/utils/css/__snapshots__/resolve-all-css-variables.spec.ts.snap:
--------------------------------------------------------------------------------
1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2 |
3 | exports[`resolveAllCSSVariables > handles deeply nested var() functions with complex parentheses 1`] = `
4 | ":root {
5 | --primary: blue;
6 | --secondary: red;
7 | --fallback: green;
8 | --size: 20px;
9 | }
10 |
11 | .box {
12 | color: blue;
13 | width: 20px;
14 | border: 1px solid black;
15 | --r: 100;
16 | --b: 10;
17 | background: rgb(100, 0, 10);
18 | }"
19 | `;
20 |
21 | exports[`resolveAllCSSVariables > handles nested var() functions in fallbacks 1`] = `
22 | ":root {
23 | --fallback-width: 300px;
24 | }
25 |
26 | .box {
27 | width: 300px;
28 | height: 250px;
29 | }"
30 | `;
31 |
32 | exports[`resolveAllCSSVariables > ignores @layer (properties) defined for browser compatibility 1`] = `
33 | "/*! tailwindcss v4.1.12 | MIT License | https://tailwindcss.com */
34 | @layer properties;
35 | @layer theme, base, components, utilities;
36 | @layer theme {
37 | :root, :host {
38 | --color-red-500: oklch(63.7% 0.237 25.331);
39 | --color-blue-400: oklch(70.7% 0.165 254.624);
40 | --color-blue-600: oklch(54.6% 0.245 262.881);
41 | --color-gray-200: oklch(92.8% 0.006 264.531);
42 | --color-black: #000;
43 | --color-white: #fff;
44 | --spacing: 0.25rem;
45 | --text-sm: 0.875rem;
46 | --text-sm--line-height: calc(1.25 / 0.875);
47 | --radius-md: 0.375rem;
48 | }
49 | }
50 | @layer utilities {
51 | .mt-8 {
52 | margin-top: calc(0.25rem * 8);
53 | }
54 | .rounded-md {
55 | border-radius: 0.375rem;
56 | }
57 | .bg-blue-600 {
58 | background-color: oklch(54.6% 0.245 262.881);
59 | }
60 | .bg-red-500 {
61 | background-color: oklch(63.7% 0.237 25.331);
62 | }
63 | .bg-white {
64 | background-color: #fff;
65 | }
66 | .p-4 {
67 | padding: calc(0.25rem * 4);
68 | }
69 | .px-3 {
70 | padding-inline: calc(0.25rem * 3);
71 | }
72 | .py-2 {
73 | padding-block: calc(0.25rem * 2);
74 | }
75 | .text-sm {
76 | font-size: 0.875rem;
77 | line-height: calc(1.25 / 0.875);
78 | }
79 | .text-\\[14px\\] {
80 | font-size: 14px;
81 | }
82 | .leading-\\[24px\\] {
83 | --tw-leading: 24px;
84 | line-height: 24px;
85 | }
86 | .text-black {
87 | color: #000;
88 | }
89 | .text-blue-400 {
90 | color: oklch(70.7% 0.165 254.624);
91 | }
92 | .text-blue-600 {
93 | color: oklch(54.6% 0.245 262.881);
94 | }
95 | .text-gray-200 {
96 | color: oklch(92.8% 0.006 264.531);
97 | }
98 | .no-underline {
99 | text-decoration-line: none;
100 | }
101 | }
102 | @property --tw-leading {
103 | syntax: "*";
104 | inherits: false;
105 | }
106 | @layer properties {
107 | @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
108 | *, ::before, ::after, ::backdrop {
109 | --tw-leading: initial;
110 | }
111 | }
112 | }
113 | "
114 | `;
115 |
116 | exports[`resolveAllCSSVariables > keeps variable usages if it cant find their declaration 1`] = `
117 | ".box {
118 | width: var(--width);
119 | }"
120 | `;
121 |
122 | exports[`resolveAllCSSVariables > uses fallback values when variable definition is not found 1`] = `
123 | ".box {
124 | width: 150px;
125 | height: 200px;
126 | margin: 10px 20px;
127 | }"
128 | `;
129 |
130 | exports[`resolveAllCSSVariables > works for variables across different CSS layers 1`] = `
131 | "@layer base {
132 | :root {
133 | --width: 100px;
134 | }
135 | }
136 |
137 | @layer utilities {
138 | .box {
139 | width: 100px;
140 | }
141 | }"
142 | `;
143 |
144 | exports[`resolveAllCSSVariables > works with a variable set in a layer, and used in another through a media query 1`] = `
145 | "@layer theme {
146 | :root {
147 | --color-blue-300: blue;
148 | }
149 | }
150 |
151 | @layer utilities {
152 | .sm\\:bg-blue-300 {
153 | @media (width >= 40rem) {
154 | background-color: blue;
155 | }
156 | }
157 | }"
158 | `;
159 |
160 | exports[`resolveAllCSSVariables > works with multiple variables in the same declaration 1`] = `
161 | ":root {
162 | --top: 101px;
163 | --bottom: 102px;
164 | --right: 103px;
165 | --left: 104px;
166 | }
167 |
168 | .box {
169 | margin: 101px 103px 102px 104px;
170 | }"
171 | `;
172 |
173 | exports[`resolveAllCSSVariables > works with simple css variables on a :root 1`] = `
174 | ":root {
175 | --width: 100px;
176 | }
177 |
178 | .box {
179 | width: 100px;
180 | }"
181 | `;
182 |
183 | exports[`resolveAllCSSVariables > works with variables set in the same rule 1`] = `
184 | ".box {
185 | --width: 200px;
186 | width: 200px;
187 | }
188 |
189 | @media (min-width: 1280px) {
190 | .xl\\:bg-green-500 {
191 | --tw-bg-opacity: 1;
192 | background-color: rgb(34 197 94 / 1)
193 | }
194 | }
195 | "
196 | `;
197 |
--------------------------------------------------------------------------------
/src/lib/components/__tests__/rendering.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest';
2 | import Renderer from '$lib/render/index.js';
3 | import Column from '../Column.svelte';
4 | import Heading from '../Heading.svelte';
5 | import Html from '../Html.svelte';
6 | import Link from '../Link.svelte';
7 | import Preview from '../Preview.svelte';
8 | import Text from '../Text.svelte';
9 |
10 | const testChildren = () => 'test';
11 |
12 | describe('Component Unique Features', () => {
13 | describe('Html', () => {
14 | it('should render with default lang and dir attributes', async () => {
15 | const renderer = new Renderer();
16 | const html = await renderer.render(Html, { props: {} });
17 | expect(html).toMatchSnapshot();
18 | });
19 |
20 | it('should render with RTL direction', async () => {
21 | const renderer = new Renderer();
22 | const html = await renderer.render(Html, { props: { lang: 'ar', dir: 'rtl' } });
23 | expect(html).toMatchSnapshot();
24 | });
25 | });
26 |
27 | describe('Text', () => {
28 | it('should render with custom element tag (as prop)', async () => {
29 | const renderer = new Renderer();
30 | const html = await renderer.render(Text, {
31 | props: { as: 'h1', style: 'font-size:32px;', children: testChildren }
32 | });
33 | expect(html).toMatchSnapshot();
34 | });
35 | });
36 |
37 | describe('Column', () => {
38 | it('should handle colspan attribute', async () => {
39 | const renderer = new Renderer();
40 | const html = await renderer.render(Column, { props: { colspan: 2, children: testChildren } });
41 | expect(html).toMatchSnapshot();
42 | });
43 |
44 | it('should handle align attribute', async () => {
45 | const renderer = new Renderer();
46 | const html = await renderer.render(Column, {
47 | props: { align: 'center', children: testChildren }
48 | });
49 | expect(html).toMatchSnapshot();
50 | });
51 | });
52 |
53 | describe('Heading', () => {
54 | it('should render different heading levels', async () => {
55 | const levels = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const;
56 | const renderer = new Renderer();
57 |
58 | for (const level of levels) {
59 | const html = await renderer.render(Heading, {
60 | props: { as: level, children: testChildren }
61 | });
62 | expect(html).toMatchSnapshot();
63 | }
64 | });
65 |
66 | it('should apply margin shorthand (m)', async () => {
67 | const renderer = new Renderer();
68 | const html = await renderer.render(Heading, {
69 | props: { as: 'h1', m: '20', children: testChildren }
70 | });
71 | expect(html).toMatchSnapshot();
72 | });
73 |
74 | it('should apply horizontal margin (mx)', async () => {
75 | const renderer = new Renderer();
76 | const html = await renderer.render(Heading, {
77 | props: { as: 'h2', mx: '16', children: testChildren }
78 | });
79 | expect(html).toMatchSnapshot();
80 | });
81 |
82 | it('should apply vertical margin (my)', async () => {
83 | const renderer = new Renderer();
84 | const html = await renderer.render(Heading, {
85 | props: { as: 'h3', my: '24', children: testChildren }
86 | });
87 | expect(html).toMatchSnapshot();
88 | });
89 |
90 | it('should apply individual margin props', async () => {
91 | const renderer = new Renderer();
92 | const html = await renderer.render(Heading, {
93 | props: { as: 'h1', mt: '10', mr: '15', mb: '20', ml: '25', children: testChildren }
94 | });
95 | expect(html).toMatchSnapshot();
96 | });
97 |
98 | it('should combine margin props with inline styles', async () => {
99 | const renderer = new Renderer();
100 | const html = await renderer.render(Heading, {
101 | props: { as: 'h2', style: 'color:blue;', my: '16', children: testChildren }
102 | });
103 | expect(html).toMatchSnapshot();
104 | });
105 | });
106 |
107 | describe('Link', () => {
108 | it('should have default target="_blank"', async () => {
109 | const renderer = new Renderer();
110 | const html = await renderer.render(Link, {
111 | props: { href: 'https://example.com', children: testChildren }
112 | });
113 | expect(html).toMatchSnapshot();
114 | });
115 |
116 | it('should allow custom target', async () => {
117 | const renderer = new Renderer();
118 | const html = await renderer.render(Link, {
119 | props: { href: 'https://example.com', target: '_self', children: testChildren }
120 | });
121 | expect(html).toMatchSnapshot();
122 | });
123 | });
124 |
125 | describe('Preview', () => {
126 | it('should truncate preview text to max length', async () => {
127 | const longText = 'A'.repeat(200);
128 | const renderer = new Renderer();
129 | const html = await renderer.render(Preview, { props: { preview: longText } });
130 | expect(html).toMatchSnapshot();
131 | });
132 |
133 | it('should add whitespace padding for short text', async () => {
134 | const shortText = 'Short';
135 | const renderer = new Renderer();
136 | const html = await renderer.render(Preview, { props: { preview: shortText } });
137 | expect(html).toMatchSnapshot();
138 | });
139 | });
140 | });
141 |
--------------------------------------------------------------------------------
/src/routes/docs/+layout.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 | Docs · Better Svelte Email
18 |
19 |
20 |
23 |
88 |
89 |
90 |
91 | {@render children()}
92 |
93 |
94 |
95 |
96 |
176 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Test, Publish and Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | permissions:
12 | contents: write
13 | pull-requests: read
14 | id-token: write
15 |
16 | jobs:
17 | test:
18 | name: Run Tests
19 | runs-on: ubuntu-latest
20 |
21 | steps:
22 | - name: Checkout code
23 | uses: actions/checkout@v4
24 | with:
25 | fetch-depth: 0
26 |
27 | - name: Setup Bun
28 | uses: oven-sh/setup-bun@v2
29 | with:
30 | bun-version: latest
31 |
32 | - name: Install dependencies
33 | run: bun install
34 |
35 | - name: Build project
36 | run: bun run build
37 |
38 | - name: Run tests
39 | run: bun run test
40 |
41 | publish:
42 | name: Publish to NPM
43 | runs-on: ubuntu-latest
44 | needs: test
45 | if: github.event_name == 'push' && github.ref == 'refs/heads/main'
46 |
47 | steps:
48 | - name: Checkout code
49 | uses: actions/checkout@v4
50 | with:
51 | fetch-depth: 0
52 | fetch-tags: true
53 |
54 | - name: Setup Bun
55 | uses: oven-sh/setup-bun@v2
56 | with:
57 | bun-version: latest
58 |
59 | - name: Setup Node.js
60 | uses: actions/setup-node@v4
61 | with:
62 | node-version: lts/*
63 | registry-url: 'https://registry.npmjs.org'
64 |
65 | - name: Get current version
66 | id: current_version
67 | run: |
68 | VERSION=$(node -p "require('./package.json').version")
69 | echo "version=$VERSION" >> $GITHUB_OUTPUT
70 | echo "Current version: $VERSION"
71 |
72 | - name: Detect prerelease tag
73 | id: prerelease_tag
74 | run: |
75 | VERSION="${{ steps.current_version.outputs.version }}"
76 |
77 | # Check if version contains a prerelease identifier (e.g., -alpha, -beta, -rc)
78 | if [[ "$VERSION" =~ -([a-zA-Z]+)\. ]]; then
79 | TAG="${BASH_REMATCH[1]}"
80 | echo "tag=$TAG" >> $GITHUB_OUTPUT
81 | echo "is_prerelease=true" >> $GITHUB_OUTPUT
82 | echo "📦 Detected prerelease version: $VERSION → dist-tag: $TAG"
83 | else
84 | echo "tag=latest" >> $GITHUB_OUTPUT
85 | echo "is_prerelease=false" >> $GITHUB_OUTPUT
86 | echo "📦 Stable version: $VERSION → dist-tag: latest"
87 | fi
88 |
89 | - name: Check if version is published
90 | id: check_published
91 | run: |
92 | PACKAGE_NAME=$(node -p "require('./package.json').name")
93 | VERSION="${{ steps.current_version.outputs.version }}"
94 |
95 | if npm view "$PACKAGE_NAME@$VERSION" version 2>/dev/null; then
96 | echo "published=true" >> $GITHUB_OUTPUT
97 | echo "📦 Version $VERSION is already published to npm"
98 | else
99 | echo "published=false" >> $GITHUB_OUTPUT
100 | echo "✨ Version $VERSION is not published yet"
101 | fi
102 |
103 | - name: Install dependencies
104 | if: steps.check_published.outputs.published == 'false'
105 | run: bun install
106 |
107 | - name: Build package
108 | if: steps.check_published.outputs.published == 'false'
109 | run: bun run prepack
110 |
111 | - name: Publish to NPM
112 | if: steps.check_published.outputs.published == 'false'
113 | run: npm publish --provenance --access public --tag ${{ steps.prerelease_tag.outputs.tag }}
114 | env:
115 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
116 |
117 | - name: Skip publishing
118 | if: steps.check_published.outputs.published == 'true'
119 | run: |
120 | echo "⏭️ Skipping npm publish - version ${{ steps.current_version.outputs.version }} already published"
121 |
122 | outputs:
123 | version: ${{ steps.current_version.outputs.version }}
124 | should_release: ${{ steps.check_published.outputs.published == 'false' }}
125 |
126 | release:
127 | name: Create GitHub Release
128 | runs-on: ubuntu-latest
129 | needs: publish
130 | if: needs.publish.outputs.should_release == 'true'
131 |
132 | steps:
133 | - name: Checkout code
134 | uses: actions/checkout@v4
135 | with:
136 | fetch-depth: 0
137 | fetch-tags: true
138 |
139 | - name: Setup Node.js
140 | uses: actions/setup-node@v4
141 | with:
142 | node-version: lts/*
143 |
144 | - name: Create and push tag
145 | run: |
146 | git config user.name "github-actions[bot]"
147 | git config user.email "github-actions[bot]@users.noreply.github.com"
148 | git tag -a "v${{ needs.publish.outputs.version }}" -m "Release v${{ needs.publish.outputs.version }}"
149 | git push origin "v${{ needs.publish.outputs.version }}"
150 | env:
151 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
152 |
153 | - name: Generate changelog and create release
154 | run: npx changelogithub
155 | env:
156 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
157 |
--------------------------------------------------------------------------------
/src/lib/render/utils/css/resolve-all-css-variables.ts:
--------------------------------------------------------------------------------
1 | import type { Root, Declaration, Rule, AtRule, Container } from 'postcss';
2 | import valueParser from 'postcss-value-parser';
3 |
4 | interface VariableUse {
5 | declaration: Declaration;
6 | selector: string;
7 | inAtRule: boolean;
8 | atRuleSelector?: string;
9 | fallback?: string;
10 | variableName: string;
11 | raw: string;
12 | }
13 |
14 | export interface VariableDefinition {
15 | declaration: Declaration;
16 | selector: string;
17 | variableName: string;
18 | definition: string;
19 | }
20 |
21 | function getSelector(decl: Declaration): string {
22 | const parent = decl.parent;
23 | if (parent?.type === 'rule') {
24 | return (parent as Rule).selector;
25 | }
26 | return '*';
27 | }
28 |
29 | function getAtRuleSelector(decl: Declaration): string | undefined {
30 | let parent = decl.parent;
31 | while (parent) {
32 | if (parent.type === 'atrule') {
33 | // Check if parent of atrule is a rule
34 | const atRuleParent = parent.parent;
35 | if (atRuleParent?.type === 'rule') {
36 | return (atRuleParent as Rule).selector;
37 | }
38 | }
39 | if (parent.type === 'rule') {
40 | return (parent as Rule).selector;
41 | }
42 | parent = parent.parent as Container | undefined;
43 | }
44 | return undefined;
45 | }
46 |
47 | function isInAtRule(decl: Declaration): boolean {
48 | let parent = decl.parent;
49 | while (parent) {
50 | if (parent.type === 'atrule') {
51 | return true;
52 | }
53 | parent = parent.parent as Container | undefined;
54 | }
55 | return false;
56 | }
57 |
58 | function isInPropertiesLayer(decl: Declaration): boolean {
59 | let parent = decl.parent;
60 | while (parent) {
61 | if (parent.type === 'atrule') {
62 | const atRule = parent as AtRule;
63 | if (atRule.name === 'layer' && atRule.params?.includes('properties')) {
64 | return true;
65 | }
66 | }
67 | parent = parent.parent as Container | undefined;
68 | }
69 | return false;
70 | }
71 |
72 | function doSelectorsIntersect(first: string, second: string): boolean {
73 | if (first === second) return true;
74 |
75 | // Check for universal selectors
76 | if (first.includes(':root') || second.includes(':root')) return true;
77 | if (first === '*' || second === '*') return true;
78 |
79 | return false;
80 | }
81 |
82 | export function resolveAllCssVariables(root: Root) {
83 | const variableDefinitions = new Set();
84 | const variableUses: VariableUse[] = [];
85 |
86 | // First pass: collect variable definitions and uses
87 | root.walkDecls((decl) => {
88 | // Skip @layer (properties) { ... } to avoid variable resolution conflicts
89 | if (isInPropertiesLayer(decl)) {
90 | return;
91 | }
92 |
93 | if (decl.prop.startsWith('--')) {
94 | variableDefinitions.add({
95 | declaration: decl,
96 | selector: getSelector(decl),
97 | variableName: decl.prop,
98 | definition: decl.value
99 | });
100 | } else if (decl.value.includes('var(')) {
101 | const parseVariableUses = (value: string) => {
102 | const parsed = valueParser(value);
103 |
104 | parsed.walk((node) => {
105 | if (node.type === 'function' && node.value === 'var') {
106 | const varNameNode = node.nodes[0];
107 | const varName = varNameNode ? valueParser.stringify(varNameNode).trim() : '';
108 |
109 | // Find fallback (after the comma)
110 | let fallback: string | undefined;
111 | const commaIndex = node.nodes.findIndex((n) => n.type === 'div' && n.value === ',');
112 | if (commaIndex !== -1) {
113 | fallback = valueParser.stringify(node.nodes.slice(commaIndex + 1)).trim();
114 | }
115 |
116 | const raw = valueParser.stringify(node);
117 |
118 | variableUses.push({
119 | declaration: decl,
120 | selector: getSelector(decl),
121 | inAtRule: isInAtRule(decl),
122 | atRuleSelector: getAtRuleSelector(decl),
123 | fallback,
124 | variableName: varName,
125 | raw
126 | });
127 |
128 | // If fallback contains var(), recursively parse those too
129 | if (fallback?.includes('var(')) {
130 | parseVariableUses(fallback);
131 | }
132 | }
133 | });
134 | };
135 |
136 | parseVariableUses(decl.value);
137 | }
138 | });
139 |
140 | // Second pass: resolve variables
141 | for (const use of variableUses) {
142 | let hasReplaced = false;
143 |
144 | for (const definition of variableDefinitions) {
145 | if (use.variableName !== definition.variableName) {
146 | continue;
147 | }
148 |
149 | // Check if use is in an at-rule and definition is in a matching rule
150 | if (
151 | use.inAtRule &&
152 | use.atRuleSelector &&
153 | doSelectorsIntersect(use.atRuleSelector, definition.selector)
154 | ) {
155 | use.declaration.value = use.declaration.value.replaceAll(use.raw, definition.definition);
156 | hasReplaced = true;
157 | break;
158 | }
159 |
160 | // Check if both are in rules with matching selectors
161 | if (!use.inAtRule && doSelectorsIntersect(use.selector, definition.selector)) {
162 | use.declaration.value = use.declaration.value.replaceAll(use.raw, definition.definition);
163 | hasReplaced = true;
164 | break;
165 | }
166 | }
167 |
168 | if (!hasReplaced && use.fallback) {
169 | use.declaration.value = use.declaration.value.replaceAll(use.raw, use.fallback);
170 | }
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/src/routes/preview/[...email]/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { env } from '$env/dynamic/private';
2 | import type { PreviewData } from '$lib/preview/index.js';
3 | import type { RequestEvent } from '@sveltejs/kit';
4 | import Renderer, { type TailwindConfig } from '$lib/render/index.js';
5 | import prettier from 'prettier/standalone';
6 | import parserHtml from 'prettier/parser-html';
7 | import { pixelBasedPreset } from '$lib/render/utils/tailwindcss/pixel-based-preset.js';
8 | import fs from 'fs';
9 | import path from 'path';
10 | import { Resend } from 'resend';
11 |
12 | const resend = new Resend(env.RESEND_API_KEY ?? 're_1234');
13 |
14 | const tailwindConfig: TailwindConfig = {
15 | theme: {
16 | extend: {
17 | colors: {
18 | brand: '#FF3E00'
19 | }
20 | }
21 | },
22 | presets: [pixelBasedPreset]
23 | };
24 |
25 | const { render } = new Renderer(tailwindConfig);
26 |
27 | // Import all email components at build time using import.meta.glob
28 | // This creates a map of all email components that can be accessed at runtime
29 | const emailModules = import.meta.glob('/src/lib/emails/**/*.svelte', { eager: true });
30 |
31 | /**
32 | * Vercel-compatible email list function that uses Vite's import.meta.glob
33 | * to statically analyze email files at build time instead of runtime fs access
34 | */
35 | function emailListVercel(): PreviewData {
36 | const files = Object.keys(emailModules)
37 | .map((path) => {
38 | // Extract filename without extension from the full path
39 | // e.g., '/src/lib/emails/apple-receipt.svelte' -> 'apple-receipt'
40 | const match = path.match(/\/src\/lib\/emails\/(.+)\.svelte$/);
41 | return match ? match[1] : null;
42 | })
43 | .filter((name): name is string => name !== null);
44 |
45 | if (files.length === 0) {
46 | return { files: null, path: '/src/lib/emails' };
47 | }
48 |
49 | return { files, path: '/src/lib/emails' };
50 | }
51 |
52 | /**
53 | * Vercel-compatible createEmail action that uses pre-imported email components
54 | * instead of dynamic runtime imports
55 | */
56 | const createEmailVercel = {
57 | 'create-email': async (event: RequestEvent) => {
58 | try {
59 | const data = await event.request.formData();
60 | const file = data.get('file');
61 | const emailPath = data.get('path');
62 |
63 | if (!file || !emailPath) {
64 | return {
65 | status: 400,
66 | body: { error: 'Missing file or path parameter' }
67 | };
68 | }
69 |
70 | // Construct the full path to match the keys in emailModules
71 | const fullPath = `${emailPath}/${file}.svelte`;
72 |
73 | // Get the component from the pre-imported modules
74 | const module = emailModules[fullPath] as { default: any } | undefined;
75 |
76 | if (!module || !module.default) {
77 | throw new Error(
78 | `Failed to import email component '${file}' in '${emailPath}'. Make sure the file exists and includes the component.`
79 | );
80 | }
81 |
82 | const emailComponent = module.default;
83 |
84 | // Render the component to HTML
85 | const html = await render(emailComponent);
86 |
87 | const source = fs.readFileSync(
88 | path.resolve(process.cwd(), path.relative('/', fullPath)),
89 | 'utf8'
90 | );
91 |
92 | // Remove all HTML comments from the body before formatting
93 | const formattedHtml = await prettier.format(html, {
94 | parser: 'html',
95 | plugins: [parserHtml]
96 | });
97 |
98 | return {
99 | body: formattedHtml,
100 | source
101 | };
102 | } catch (error) {
103 | console.error('Error rendering email:', error);
104 | return {
105 | status: 500,
106 | error: {
107 | message: error instanceof Error ? error.message : 'Failed to render email'
108 | }
109 | };
110 | }
111 | }
112 | };
113 |
114 | const sendEmailVercel = {
115 | 'send-email': async (event: RequestEvent): Promise<{ success: boolean; error: any }> => {
116 | const data = await event.request.formData();
117 | const emailPath = data.get('path');
118 | const file = data.get('file');
119 |
120 | if (!file || !emailPath) {
121 | return {
122 | success: false,
123 | error: { message: 'Missing file or path parameter' }
124 | };
125 | }
126 |
127 | // Construct the full path to match the keys in emailModules
128 | const fullPath = `${emailPath}/${file}.svelte`;
129 |
130 | // Get the component from the pre-imported modules
131 | const module = emailModules[fullPath] as { default: any } | undefined;
132 |
133 | if (!module || !module.default) {
134 | throw new Error(
135 | `Failed to import email component '${file}' in '${emailPath}'. Make sure the file exists and includes the
component.`
136 | );
137 | }
138 |
139 | const emailComponent = module.default;
140 |
141 | const html = await render(emailComponent);
142 |
143 | const email = {
144 | from: 'svelte-email-tailwind