diff --git a/CHANGELOG.md b/CHANGELOG.md
index ccec013..2308f17 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
All notable changes to `email-templates` will be documented in this file
+## 5.7.0 - unreleased
+ - Email block composer. A template can build its body from ordered blocks (`layout` JSON column) instead of the single `content` field: Text, Hero, Two columns, Button, Image, Divider / spacer and Saved block. Each block renders as one full-width table row with inline styles, explicit background colours and Outlook ghost tables; buttons are bulletproof (VML). Templates without a layout render exactly as before. Block mode is not offered on quote templates.
+ - Block types are classes implementing `EmailBlockDefinition`, listed by the `EmailBlockRegistry` singleton (`block_types` config or `register()` at runtime). `EmailLayoutRenderer` renders a layout; the send path, the template preview and the Builder block previews all use it.
+ - New **Email Blocks** resource (block library, `EmailBlock` model, `vb_email_blocks` table) for reusable saved blocks, with a preview.
+ - `layout.blade.php` / `layout_preview.blade.php` render the theme header, hero title, blocks, support block, footer and, when the mailable has an `$unsubscribeUrl`, an unsubscribe line. New parts: `_support_block_standalone`, `_unsubscribe`, `_block_open`, `_block_close`, `_block_button`.
+ - `EmailTemplate::usesLayout()` and `renderViewPath()`; `BuildGenericEmail` and the template screenshot actions render through `renderViewPath()`.
+ - New config keys: `blocks_table_name`, `block_types`, `block_images`, `block_images_disk`, `font_family`, `navigation.blocks`. New migration stubs: `add_layout_to_email_templates_table`, `create_email_blocks_table`.
+
## 5.6.3 - 2026-09-17
- The vbtokens editor plugin URL is root-relative unless `app.asset_url` is set. It was built with `asset()` at boot, and under `config:cache` that runs in the console, where the host comes from `APP_URL`; a panel on another domain then fetched the plugin cross-host, where a CSP or an auth gate blocked it and the Insert token menu disappeared. `EmailTemplatesServiceProvider::tokenPluginUrl()` is public for applications registering their own profiles.
diff --git a/README.md b/README.md
index b605de3..41e6e31 100644
--- a/README.md
+++ b/README.md
@@ -599,6 +599,126 @@ Not all email clients (e.g., Outlook) render CSS from a stylesheet effectively.
best to **put styles inline**. For checking how your email looks across different
clients, [Litmus Email Previews](https://www.litmus.com/landing-page/email-previews) is highly recommended.
+### Email blocks
+
+A template can build its body from an ordered list of blocks instead of the single rich text `content` field. On the
+template form, switch on **Build the body from blocks**: the current body is copied into a first Text block, and the
+Builder replaces the editor. Switching it off stores no layout and the original `content` is used again. Templates
+without a layout send exactly as before. Block mode is not offered on templates where `is_quote_template` is true.
+
+In block mode the email still renders the theme's logo header, hero title, "Need help?" support block and footer around
+the blocks. A mailable with a public `$unsubscribeUrl` property (for example a campaign mail) also gets an unsubscribe
+line under the footer.
+
+Each block renders as one full-width table row with inline styles, an Outlook ghost table at `content_width`, and
+explicit background colours, so it holds up in Outlook and Gmail. The package ships these block types:
+
+| Name | Label | Fields |
+|---|---|---|
+| `rich_text` | Text | rich text |
+| `hero` | Hero | image + alt, heading, text, optional button |
+| `two_column` | Two columns | image + alt and rich text per column; columns stack on narrow screens |
+| `button` | Button | label, link, alignment (bulletproof button with VML for Outlook) |
+| `image` | Image | image, alt text, optional link |
+| `divider` | Divider / spacer | small, medium or large spacing, optional line |
+| `saved_block` | Saved block | a block from the block library |
+
+Links accept an `http(s)://` URL or a single token such as `##tokenUrl##`. Tokens are replaced in text, headings,
+button labels and links when the email is sent. Builder previews and the template preview use the same renderer as the
+send, so they match the email (Builder previews show tokens unreplaced).
+
+#### Block library
+
+The **Email Blocks** resource stores reusable groups of blocks (a signature, an event banner). Add one to a template with
+a **Saved block** item; it renders from the library's current content, so editing the library block updates every
+template that uses it. A saved block cannot contain another saved block. A deleted or inactive library block is left out
+of the email (and a warning is logged).
+
+Register a policy for `Visualbuilder\EmailTemplates\Models\EmailBlock` in your application so only the users you choose
+can view and edit library blocks.
+
+Block images are uploaded with public visibility to `block_images` on `block_images_disk`. Mail clients must be able to
+fetch them, so use a disk with public URLs:
+
+```php
+'block_images' => 'media/email-templates/blocks',
+'block_images_disk' => 's3_public', // null: filament.default_filesystem_disk, then 'public'
+'font_family' => "'Lato', Helvetica, Arial, sans-serif",
+'blocks_table_name' => 'vb_email_blocks',
+```
+
+Upgrading an existing installation: add a nullable JSON `layout` column to the templates table and create the blocks
+table (stubs: `database/migrations/add_layout_to_email_templates_table.php.stub` and
+`create_email_blocks_table.php.stub`). If you published the config, copy the new keys above plus `navigation.blocks`
+and `block_types` from the package config.
+
+#### Adding a block type
+
+Block types are classes implementing `Visualbuilder\EmailTemplates\Blocks\Contracts\EmailBlockDefinition`, listed by
+`Visualbuilder\EmailTemplates\Blocks\EmailBlockRegistry`. Both the form (`EmailLayoutBuilder`) and the output
+(`EmailLayoutRenderer`) read the registry, so a new block type is one class plus one email view. Extending
+`AbstractEmailBlock` gives you token replacement (`tokenFields()`), public image URLs (`imageFields()`) and rendering
+through `resources/views/vendor/vb-email-templates/email/blocks/{name}.blade.php`:
+
+```php
+namespace App\EmailBlocks;
+
+use Filament\Forms\Components\TextInput;
+use Filament\Support\Icons\Heroicon;
+use Visualbuilder\EmailTemplates\Blocks\AbstractEmailBlock;
+
+class QuoteBlock extends AbstractEmailBlock
+{
+ public function name(): string { return 'quote'; } // stored in layouts: never rename
+
+ public function label(): string { return 'Quote'; }
+
+ public function icon(): Heroicon { return Heroicon::OutlinedChatBubbleLeft; }
+
+ public function schema(): array
+ {
+ return [
+ TextInput::make('quote')->required()->maxLength(300),
+ TextInput::make('author')->maxLength(120),
+ ];
+ }
+
+ public function summary(array $data): ?string { return $data['author'] ?? null; }
+
+ protected function tokenFields(): array { return ['quote']; }
+}
+```
+
+The view receives `$block` (the block's data after token and image processing) and `$theme` (the theme colours). Wrap
+your markup in the shared row partials so it lines up with the other blocks:
+
+```blade
+{{-- resources/views/vendor/vb-email-templates/email/blocks/quote.blade.php --}}
+@include('vb-email-templates::email.parts._block_open', ['theme' => $theme])
+
` wrapper, override
+`email/parts/_support_block_standalone.blade.php` to wrap it for block mode.
+
### Translations
Each email template is identified by a key and a language:
diff --git a/config/filament-email-templates.php b/config/filament-email-templates.php
index 413ebc7..4258c44 100644
--- a/config/filament-email-templates.php
+++ b/config/filament-email-templates.php
@@ -9,6 +9,7 @@
*/
'table_name' => 'vb_email_templates',
'theme_table_name' => 'vb_email_templates_themes',
+ 'blocks_table_name' => 'vb_email_blocks',
/**
* Flag-icon stylesheet
@@ -103,6 +104,14 @@
'cluster' => false,
'position' => SubNavigationPosition::Top
],
+ 'blocks' => [
+ 'sort' => 15,
+ 'label' => 'Email Blocks',
+ 'icon' => 'heroicon-o-squares-2x2',
+ 'group' => 'Content',
+ 'cluster' => false,
+ 'position' => SubNavigationPosition::Top
+ ],
],
//Email templates will be copied to resources/views/vendor/vb-email-templates/email
@@ -134,6 +143,30 @@
//Content Width in Pixels
'content_width' => '600',
+ //Font stack used by the email block views
+ 'font_family' => "'Lato', Helvetica, Arial, sans-serif",
+
+ /**
+ * Email blocks (block composer)
+ *
+ * block_types: the block classes offered in the "Add block" menu, in order.
+ * Append your own EmailBlockDefinition classes here.
+ * block_images: upload directory for block images
+ * block_images_disk: disk for block images; null uses filament.default_filesystem_disk,
+ * then 'public'. Must serve public URLs that mail clients can fetch.
+ */
+ 'block_types' => [
+ \Visualbuilder\EmailTemplates\Blocks\Types\RichTextBlock::class,
+ \Visualbuilder\EmailTemplates\Blocks\Types\HeroBlock::class,
+ \Visualbuilder\EmailTemplates\Blocks\Types\TwoColumnBlock::class,
+ \Visualbuilder\EmailTemplates\Blocks\Types\ButtonBlock::class,
+ \Visualbuilder\EmailTemplates\Blocks\Types\ImageBlock::class,
+ \Visualbuilder\EmailTemplates\Blocks\Types\DividerBlock::class,
+ \Visualbuilder\EmailTemplates\Blocks\Types\SavedBlock::class,
+ ],
+ 'block_images' => 'media/email-templates/blocks',
+ 'block_images_disk' => null,
+
//Contact details included in default email templates
'customer-services' => [
'email' => 'support@yourcompany.com',
diff --git a/database/migrations/add_layout_to_email_templates_table.php.stub b/database/migrations/add_layout_to_email_templates_table.php.stub
new file mode 100644
index 0000000..a54877b
--- /dev/null
+++ b/database/migrations/add_layout_to_email_templates_table.php.stub
@@ -0,0 +1,30 @@
+json('layout')->nullable()->after('content');
+ });
+ }
+ }
+
+ public function down(): void
+ {
+ $table = config('filament-email-templates.table_name');
+
+ if (Schema::hasColumn($table, 'layout')) {
+ Schema::table($table, function (Blueprint $table) {
+ $table->dropColumn('layout');
+ });
+ }
+ }
+};
diff --git a/database/migrations/create_email_blocks_table.php.stub b/database/migrations/create_email_blocks_table.php.stub
new file mode 100644
index 0000000..4a58015
--- /dev/null
+++ b/database/migrations/create_email_blocks_table.php.stub
@@ -0,0 +1,27 @@
+id();
+ $table->string('name', 120);
+ $table->string('description', 255)->nullable();
+ $table->json('layout');
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ $table->softDeletes();
+ $table->index('is_active');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists(config('filament-email-templates.blocks_table_name', 'vb_email_blocks'));
+ }
+};
diff --git a/resources/views/email/blocks/button.blade.php b/resources/views/email/blocks/button.blade.php
new file mode 100644
index 0000000..322d158
--- /dev/null
+++ b/resources/views/email/blocks/button.blade.php
@@ -0,0 +1,4 @@
+
+@include('vb-email-templates::email.parts._block_open', ['theme' => $theme, 'padding' => '10px 30px 10px 30px'])
+@include('vb-email-templates::email.parts._block_button', ['theme' => $theme, 'label' => $block['label'] ?? '', 'url' => $block['url'] ?? '', 'align' => $block['align'] ?? 'center'])
+@include('vb-email-templates::email.parts._block_close')
diff --git a/resources/views/email/blocks/divider.blade.php b/resources/views/email/blocks/divider.blade.php
new file mode 100644
index 0000000..3023c06
--- /dev/null
+++ b/resources/views/email/blocks/divider.blade.php
@@ -0,0 +1,13 @@
+
+@php
+ $space = ['small' => 16, 'medium' => 32, 'large' => 48][$block['spacing'] ?? 'medium'] ?? 32;
+ $showLine = (bool) ($block['show_line'] ?? true);
+ $cellBg = $theme['content_bg_color'] ?? '#ffffff';
+@endphp
+@include('vb-email-templates::email.parts._block_open', ['theme' => $theme, 'padding' => $space.'px 30px '.$space.'px 30px'])
+
+@include('vb-email-templates::email.parts._block_close')
diff --git a/resources/views/email/blocks/hero.blade.php b/resources/views/email/blocks/hero.blade.php
new file mode 100644
index 0000000..76af09c
--- /dev/null
+++ b/resources/views/email/blocks/hero.blade.php
@@ -0,0 +1,14 @@
+
+@php($imageWidth = (int) config('filament-email-templates.content_width') - 60)
+@include('vb-email-templates::email.parts._block_open', ['theme' => $theme, 'padding' => '30px 30px 30px 30px'])
+@if(filled($block['image'] ?? null))
+
+@endif
+{{ $block['heading'] ?? '' }}
+@if(filled($block['text'] ?? null))
+ {!! nl2br(e($block['text'])) !!}
+@endif
+@if(filled($block['button_label'] ?? null) && filled($block['button_url'] ?? null))
+ @include('vb-email-templates::email.parts._block_button', ['theme' => $theme, 'label' => $block['button_label'], 'url' => $block['button_url'], 'align' => 'left'])
+@endif
+@include('vb-email-templates::email.parts._block_close')
diff --git a/resources/views/email/blocks/image.blade.php b/resources/views/email/blocks/image.blade.php
new file mode 100644
index 0000000..f7f9948
--- /dev/null
+++ b/resources/views/email/blocks/image.blade.php
@@ -0,0 +1,9 @@
+
+@php($imageWidth = (int) config('filament-email-templates.content_width') - 60)
+@if(filled($block['image'] ?? null))
+@include('vb-email-templates::email.parts._block_open', ['theme' => $theme, 'align' => 'center'])
+@if(filled($block['link_url'] ?? null))@endif
+
+@if(filled($block['link_url'] ?? null))@endif
+@include('vb-email-templates::email.parts._block_close')
+@endif
diff --git a/resources/views/email/blocks/rich_text.blade.php b/resources/views/email/blocks/rich_text.blade.php
new file mode 100644
index 0000000..5db3b49
--- /dev/null
+++ b/resources/views/email/blocks/rich_text.blade.php
@@ -0,0 +1,4 @@
+
+@include('vb-email-templates::email.parts._block_open', ['theme' => $theme])
+{!! $block['content'] ?? '' !!}
+@include('vb-email-templates::email.parts._block_close')
diff --git a/resources/views/email/blocks/two_column.blade.php b/resources/views/email/blocks/two_column.blade.php
new file mode 100644
index 0000000..d342839
--- /dev/null
+++ b/resources/views/email/blocks/two_column.blade.php
@@ -0,0 +1,30 @@
+
+@php
+ // Each column holds (content_width - 80) / 2 of content; 10px side padding per
+ // column gives a 20px gutter side by side and keeps the 30px edge when stacked.
+ $column = intdiv((int) config('filament-email-templates.content_width') - 80, 2);
+ $cellBg = $theme['content_bg_color'] ?? '#ffffff';
+@endphp
+@include('vb-email-templates::email.parts._block_open', ['theme' => $theme, 'padding' => '20px 20px 20px 20px', 'align' => 'center'])
+
+
+@foreach(['left', 'right'] as $side)
+@if($side === 'right')
+
+@endif
+
+
+
+
+ @if(filled($block[$side.'_image'] ?? null))
+
+ @endif
+ {!! $block[$side.'_content'] ?? '' !!}
+ |
+
+
+
+@endforeach
+
+
+@include('vb-email-templates::email.parts._block_close')
diff --git a/resources/views/email/layout.blade.php b/resources/views/email/layout.blade.php
new file mode 100644
index 0000000..41843b0
--- /dev/null
+++ b/resources/views/email/layout.blade.php
@@ -0,0 +1,15 @@
+@include('vb-email-templates::email.parts._head')
+
+@include('vb-email-templates::email.parts._body')
+
+@include('vb-email-templates::email.parts._hero_title')
+
+{!! $data['blocks'] ?? '' !!}
+
+@include('vb-email-templates::email.parts._support_block_standalone')
+
+@include('vb-email-templates::email.parts._footer')
+
+@include('vb-email-templates::email.parts._unsubscribe')
+
+@include('vb-email-templates::email.parts._closing_tags')
diff --git a/resources/views/email/layout_preview.blade.php b/resources/views/email/layout_preview.blade.php
new file mode 100644
index 0000000..1bf9251
--- /dev/null
+++ b/resources/views/email/layout_preview.blade.php
@@ -0,0 +1,24 @@
+
+@php
+ if (isset($data['colours'])) {
+ $data['theme'] = $data['colours'];
+ } elseif (isset($this) && isset($this->data['colours'])) {
+ $data['theme'] = $this->data['colours'];
+ }
+@endphp
+
+
+
+@include('vb-email-templates::email.parts._body')
+
+@include('vb-email-templates::email.parts._hero_title')
+
+{!! $data['blocks'] ?? '' !!}
+
+@include('vb-email-templates::email.parts._support_block_standalone')
+
+@include('vb-email-templates::email.parts._footer')
+
+@include('vb-email-templates::email.parts._unsubscribe')
+
+
diff --git a/resources/views/email/parts/_block_button.blade.php b/resources/views/email/parts/_block_button.blade.php
new file mode 100644
index 0000000..e608c36
--- /dev/null
+++ b/resources/views/email/parts/_block_button.blade.php
@@ -0,0 +1,34 @@
+{{--
+ Bulletproof button: VML roundrect for Outlook desktop, a padded link elsewhere.
+ Expects $theme, $label, $url; optional $align (left|center|right).
+--}}
+@php
+ $buttonBg = $theme['button_bg_color'] ?? '#1e88e5';
+ $buttonColor = $theme['button_color'] ?? '#ffffff';
+ $buttonAlign = in_array($align ?? 'center', ['left', 'center', 'right'], true) ? ($align ?? 'center') : 'center';
+ $buttonWidth = min(
+ (int) config('filament-email-templates.content_width') - 60,
+ max(160, mb_strlen((string) $label) * 11 + 60)
+ );
+@endphp
+
diff --git a/resources/views/email/parts/_block_close.blade.php b/resources/views/email/parts/_block_close.blade.php
new file mode 100644
index 0000000..ffa40d9
--- /dev/null
+++ b/resources/views/email/parts/_block_close.blade.php
@@ -0,0 +1,10 @@
+ |
+
+
+
+
+
diff --git a/resources/views/email/parts/_block_open.blade.php b/resources/views/email/parts/_block_open.blade.php
new file mode 100644
index 0000000..5747aef
--- /dev/null
+++ b/resources/views/email/parts/_block_open.blade.php
@@ -0,0 +1,21 @@
+{{--
+ Opens one block row: a full-width band in body_bg_color (as _content.blade.php),
+ an Outlook ghost table at content_width, and the content cell.
+ Expects $theme; optional $padding and $align for the content cell.
+ Close with _block_close.
+--}}
+@php
+ $contentWidth = (int) config('filament-email-templates.content_width');
+ $bandBg = $theme['body_bg_color'] ?? '#ffffff';
+ $cellBg = $theme['content_bg_color'] ?? '#ffffff';
+@endphp
+
+
+
+
+
+
diff --git a/resources/views/email/parts/_support_block_standalone.blade.php b/resources/views/email/parts/_support_block_standalone.blade.php
new file mode 100644
index 0000000..b6799bc
--- /dev/null
+++ b/resources/views/email/parts/_support_block_standalone.blade.php
@@ -0,0 +1,2 @@
+{{-- Block mode: a self-contained support row. Override this file when your _support_block is a nested fragment. --}}
+@include('vb-email-templates::email.parts._support_block')
diff --git a/resources/views/email/parts/_unsubscribe.blade.php b/resources/views/email/parts/_unsubscribe.blade.php
new file mode 100644
index 0000000..3706949
--- /dev/null
+++ b/resources/views/email/parts/_unsubscribe.blade.php
@@ -0,0 +1,11 @@
+@if(! empty($data['unsubscribeUrl']))
+@php($unsubscribeBg = $data['theme']['footer_bg_color'] ?? $data['theme']['body_bg_color'] ?? '#ffffff')
+
+
+
+ |
+ You are receiving this because you subscribed. Unsubscribe
+ |
+
+
+@endif
diff --git a/resources/views/forms/block-preview.blade.php b/resources/views/forms/block-preview.blade.php
new file mode 100644
index 0000000..ae9221b
--- /dev/null
+++ b/resources/views/forms/block-preview.blade.php
@@ -0,0 +1,18 @@
+@php
+ $theme = \Visualbuilder\EmailTemplates\Models\EmailTemplateTheme::query()->where('is_default', true)->first()?->colours ?? [];
+ $html = app(\Visualbuilder\EmailTemplates\Layout\EmailLayoutRenderer::class)->withTheme($theme)->renderBlock($type, $data, null);
+
+ // The block HTML holds editor content, so it is shown in a sandboxed iframe (no scripts,
+ // opaque origin) rather than inline in the admin page. Blade escapes it into srcdoc.
+ $document = ''
+ .'';
+@endphp
+
diff --git a/src/Blocks/AbstractEmailBlock.php b/src/Blocks/AbstractEmailBlock.php
new file mode 100644
index 0000000..ca8be43
--- /dev/null
+++ b/src/Blocks/AbstractEmailBlock.php
@@ -0,0 +1,156 @@
+
+ */
+ protected function tokenFields(): array
+ {
+ return [];
+ }
+
+ /**
+ * Data keys holding a stored upload path, replaced by a public URL.
+ *
+ * @return array
+ */
+ protected function imageFields(): array
+ {
+ return [];
+ }
+
+ /**
+ * Public URL for a stored block image. An image picked but not saved yet
+ * (a Livewire temporary upload) returns null, so a preview leaves it out
+ * until the record is saved.
+ */
+ public static function imageUrl(mixed $value): ?string
+ {
+ if (is_array($value)) {
+ $value = Arr::first($value);
+ }
+
+ if ($value instanceof TemporaryUploadedFile || blank($value) || ! is_string($value)) {
+ return null;
+ }
+
+ return Str::isUrl($value) ? $value : Storage::disk(static::imageDisk())->url($value);
+ }
+
+ /**
+ * Images must be served from a public URL that mail clients can fetch;
+ * asset() would point at the (possibly protected) admin domain.
+ */
+ public static function imageDisk(): string
+ {
+ return config('filament-email-templates.block_images_disk')
+ ?: (config('filament.default_filesystem_disk') ?: 'public');
+ }
+
+ public function view(BlockRenderMode $mode): ?string
+ {
+ return match ($mode) {
+ BlockRenderMode::Email => 'vb-email-templates::email.blocks.'.$this->name(),
+ };
+ }
+
+ public function summary(array $data): ?string
+ {
+ return null;
+ }
+
+ public function nestable(): bool
+ {
+ return true;
+ }
+
+ public function render(array $data, BlockRenderContext $context): string
+ {
+ $view = $this->view($context->mode);
+
+ if ($view === null) {
+ return '';
+ }
+
+ return view($view, [
+ 'block' => $this->prepare($data, $context),
+ 'theme' => $context->theme,
+ ])->render();
+ }
+
+ /**
+ * @param array $data
+ * @return array
+ */
+ protected function prepare(array $data, BlockRenderContext $context): array
+ {
+ if ($context->models !== null) {
+ foreach ($this->tokenFields() as $key) {
+ if (is_string($data[$key] ?? null)) {
+ $data[$key] = TokenHelper::replace($data[$key], $context->models);
+ }
+ }
+ }
+
+ foreach ($this->imageFields() as $key) {
+ $data[$key] = static::imageUrl($data[$key] ?? null);
+ }
+
+ return $data;
+ }
+
+ /**
+ * First $limit characters of the text in an HTML fragment, or null when empty.
+ */
+ protected static function textSummary(mixed $html, int $limit = 50): ?string
+ {
+ $text = trim(html_entity_decode(strip_tags(is_string($html) ? $html : ''), ENT_QUOTES | ENT_HTML5));
+
+ return $text === '' ? null : Str::substr($text, 0, $limit);
+ }
+
+ /** Raster image upload (no SVG) stored with public visibility on the block image disk. */
+ protected static function imageUpload(string $name): FileUpload
+ {
+ return FileUpload::make($name)
+ ->image()
+ ->acceptedFileTypes(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
+ ->disk(static::imageDisk())
+ ->visibility('public')
+ ->directory(config('filament-email-templates.block_images'))
+ ->maxSize(2048);
+ }
+
+ /** Rich text editor configured like the template body editor. */
+ protected static function richEditor(string $name): TinyEditor
+ {
+ return TinyEditor::make($name)
+ ->profile('email-template')
+ ->setCustomConfigs(fn () => ['vbtokens_list' => app(TokenRegistry::class)->menu()]);
+ }
+}
diff --git a/src/Blocks/BlockRenderContext.php b/src/Blocks/BlockRenderContext.php
new file mode 100644
index 0000000..702e0c5
--- /dev/null
+++ b/src/Blocks/BlockRenderContext.php
@@ -0,0 +1,26 @@
+ $theme
+ */
+ public function __construct(
+ public readonly EmailLayoutRenderer $renderer,
+ public readonly array $theme,
+ public readonly object|array|null $models,
+ public readonly BlockRenderMode $mode = BlockRenderMode::Email,
+ public readonly int $depth = 0,
+ ) {}
+}
diff --git a/src/Blocks/Contracts/EmailBlockDefinition.php b/src/Blocks/Contracts/EmailBlockDefinition.php
new file mode 100644
index 0000000..3ce4b7f
--- /dev/null
+++ b/src/Blocks/Contracts/EmailBlockDefinition.php
@@ -0,0 +1,44 @@
+ */
+ public function schema(): array;
+
+ /** Blade view for the mode, or null when the block has no view of its own in that mode. */
+ public function view(BlockRenderMode $mode): ?string;
+
+ /**
+ * Short text shown after the label in the Builder header (collapsed or not), or null.
+ *
+ * @param array $data
+ */
+ public function summary(array $data): ?string;
+
+ /** False when the block may not be used inside a saved block (only SavedBlock). */
+ public function nestable(): bool;
+
+ /** @param array $data */
+ public function render(array $data, BlockRenderContext $context): string;
+}
diff --git a/src/Blocks/EmailBlockRegistry.php b/src/Blocks/EmailBlockRegistry.php
new file mode 100644
index 0000000..d3f26ea
--- /dev/null
+++ b/src/Blocks/EmailBlockRegistry.php
@@ -0,0 +1,98 @@
+> The order of the "Add block" menu. */
+ public const DEFAULT_TYPES = [
+ RichTextBlock::class,
+ HeroBlock::class,
+ TwoColumnBlock::class,
+ ButtonBlock::class,
+ ImageBlock::class,
+ DividerBlock::class,
+ SavedBlock::class,
+ ];
+
+ /** @var array */
+ protected array $registered = [];
+
+ /** @var array|null */
+ protected ?array $definitions = null;
+
+ public function register(string $class): static
+ {
+ $this->registered[] = $class;
+ $this->definitions = null;
+
+ return $this;
+ }
+
+ /**
+ * @return array keyed by name()
+ */
+ public function all(): array
+ {
+ if ($this->definitions !== null) {
+ return $this->definitions;
+ }
+
+ $classes = array_merge(
+ config('filament-email-templates.block_types') ?: self::DEFAULT_TYPES,
+ $this->registered,
+ );
+
+ $definitions = [];
+
+ foreach ($classes as $class) {
+ $definition = app($class);
+
+ if (! $definition instanceof EmailBlockDefinition) {
+ throw new InvalidArgumentException("{$class} must implement EmailBlockDefinition");
+ }
+
+ // Assigning to an existing key keeps its position.
+ $definitions[$definition->name()] = $definition;
+ }
+
+ return $this->definitions = $definitions;
+ }
+
+ public function get(string $name): ?EmailBlockDefinition
+ {
+ return $this->all()[$name] ?? null;
+ }
+
+ /**
+ * Block types that may be used inside a saved block.
+ *
+ * @return array
+ */
+ public function nestable(): array
+ {
+ return array_filter($this->all(), fn (EmailBlockDefinition $definition): bool => $definition->nestable());
+ }
+}
diff --git a/src/Blocks/Filament/EmailBuilderBlock.php b/src/Blocks/Filament/EmailBuilderBlock.php
new file mode 100644
index 0000000..8ca258d
--- /dev/null
+++ b/src/Blocks/Filament/EmailBuilderBlock.php
@@ -0,0 +1,24 @@
+ $data
+ */
+ public function renderPreview(array $data): View
+ {
+ return view('vb-email-templates::forms.block-preview', ['type' => $this->getName(), 'data' => $data]);
+ }
+}
diff --git a/src/Blocks/Types/ButtonBlock.php b/src/Blocks/Types/ButtonBlock.php
new file mode 100644
index 0000000..3202fb4
--- /dev/null
+++ b/src/Blocks/Types/ButtonBlock.php
@@ -0,0 +1,58 @@
+label('Button label')
+ ->required()
+ ->maxLength(40),
+ TextInput::make('url')
+ ->label('Button link')
+ ->required()
+ ->maxLength(2048)
+ ->regex(self::URL_PATTERN),
+ Select::make('align')
+ ->label('Alignment')
+ ->options(['left' => 'Left', 'center' => 'Centre', 'right' => 'Right'])
+ ->default('center')
+ ->required(),
+ ];
+ }
+
+ public function summary(array $data): ?string
+ {
+ return filled($data['label'] ?? null) ? (string) $data['label'] : null;
+ }
+
+ protected function tokenFields(): array
+ {
+ return ['label', 'url'];
+ }
+}
diff --git a/src/Blocks/Types/DividerBlock.php b/src/Blocks/Types/DividerBlock.php
new file mode 100644
index 0000000..f002a51
--- /dev/null
+++ b/src/Blocks/Types/DividerBlock.php
@@ -0,0 +1,97 @@
+ 'Small', 'medium' => 'Medium', 'large' => 'Large'];
+
+ public function name(): string
+ {
+ return 'divider';
+ }
+
+ public function label(): string
+ {
+ return 'Divider / spacer';
+ }
+
+ public function icon(): Heroicon
+ {
+ return Heroicon::OutlinedMinus;
+ }
+
+ public function schema(): array
+ {
+ return [
+ Select::make('spacing')
+ ->label('Spacing')
+ ->options(self::SPACINGS)
+ ->default('medium')
+ ->required(),
+ Toggle::make('show_line')
+ ->label('Show a line')
+ ->default(true),
+ ];
+ }
+
+ public function summary(array $data): ?string
+ {
+ return self::SPACINGS[$data['spacing'] ?? ''] ?? null;
+ }
+
+ protected function prepare(array $data, BlockRenderContext $context): array
+ {
+ $data = parent::prepare($data, $context);
+ $data['line_color'] = self::lineColour($context->theme);
+
+ return $data;
+ }
+
+ /**
+ * body_color at 20% over content_bg_color, as a solid hex: email clients
+ * do not reliably apply opacity to borders. #e5e7eb when either colour is
+ * missing or not a hex value.
+ *
+ * @param array $theme
+ */
+ public static function lineColour(array $theme): string
+ {
+ $foreground = self::rgb($theme['body_color'] ?? null);
+ $background = self::rgb($theme['content_bg_color'] ?? null);
+
+ if ($foreground === null || $background === null) {
+ return '#e5e7eb';
+ }
+
+ $mixed = array_map(
+ fn (int $fg, int $bg): int => (int) round($fg * 0.2 + $bg * 0.8),
+ $foreground,
+ $background,
+ );
+
+ return vsprintf('#%02x%02x%02x', $mixed);
+ }
+
+ /** @return array{0: int, 1: int, 2: int}|null */
+ private static function rgb(mixed $hex): ?array
+ {
+ if (! is_string($hex) || ! preg_match('/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i', trim($hex), $matches)) {
+ return null;
+ }
+
+ $digits = strlen($matches[1]) === 3
+ ? implode('', array_map(fn (string $c): string => $c.$c, str_split($matches[1])))
+ : $matches[1];
+
+ return [hexdec(substr($digits, 0, 2)), hexdec(substr($digits, 2, 2)), hexdec(substr($digits, 4, 2))];
+ }
+}
diff --git a/src/Blocks/Types/HeroBlock.php b/src/Blocks/Types/HeroBlock.php
new file mode 100644
index 0000000..286cc22
--- /dev/null
+++ b/src/Blocks/Types/HeroBlock.php
@@ -0,0 +1,72 @@
+label('Image'),
+ TextInput::make('image_alt')
+ ->label('Image description (alt text)')
+ ->maxLength(150)
+ ->requiredWith('image'),
+ TextInput::make('heading')
+ ->label('Heading')
+ ->required()
+ ->maxLength(120),
+ Textarea::make('text')
+ ->label('Text')
+ ->maxLength(500)
+ ->rows(3),
+ TextInput::make('button_label')
+ ->label('Button label')
+ ->maxLength(40)
+ ->requiredWith('button_url'),
+ TextInput::make('button_url')
+ ->label('Button link')
+ ->maxLength(2048)
+ ->requiredWith('button_label')
+ ->regex(self::URL_PATTERN),
+ ];
+ }
+
+ public function summary(array $data): ?string
+ {
+ return filled($data['heading'] ?? null) ? (string) $data['heading'] : null;
+ }
+
+ protected function tokenFields(): array
+ {
+ return ['heading', 'text', 'button_label', 'button_url'];
+ }
+
+ protected function imageFields(): array
+ {
+ return ['image'];
+ }
+}
diff --git a/src/Blocks/Types/ImageBlock.php b/src/Blocks/Types/ImageBlock.php
new file mode 100644
index 0000000..19cf750
--- /dev/null
+++ b/src/Blocks/Types/ImageBlock.php
@@ -0,0 +1,59 @@
+label('Image')
+ ->required(),
+ TextInput::make('alt')
+ ->label('Image description (alt text)')
+ ->required()
+ ->maxLength(150),
+ TextInput::make('link_url')
+ ->label('Link')
+ ->maxLength(2048)
+ ->regex(self::URL_PATTERN),
+ ];
+ }
+
+ public function summary(array $data): ?string
+ {
+ return filled($data['alt'] ?? null) ? (string) $data['alt'] : null;
+ }
+
+ protected function tokenFields(): array
+ {
+ return ['link_url'];
+ }
+
+ protected function imageFields(): array
+ {
+ return ['image'];
+ }
+}
diff --git a/src/Blocks/Types/RichTextBlock.php b/src/Blocks/Types/RichTextBlock.php
new file mode 100644
index 0000000..d05d382
--- /dev/null
+++ b/src/Blocks/Types/RichTextBlock.php
@@ -0,0 +1,45 @@
+label('Text')
+ ->required(),
+ ];
+ }
+
+ public function summary(array $data): ?string
+ {
+ return self::textSummary($data['content'] ?? null);
+ }
+
+ protected function tokenFields(): array
+ {
+ return ['content'];
+ }
+}
diff --git a/src/Blocks/Types/SavedBlock.php b/src/Blocks/Types/SavedBlock.php
new file mode 100644
index 0000000..4da1a4a
--- /dev/null
+++ b/src/Blocks/Types/SavedBlock.php
@@ -0,0 +1,81 @@
+label('Saved block')
+ ->options(fn (): array => EmailBlock::active()->orderBy('name')->pluck('name', 'id')->all())
+ ->searchable()
+ ->required(),
+ ];
+ }
+
+ public function summary(array $data): ?string
+ {
+ return EmailBlock::withTrashed()->find($data['email_block_id'] ?? null)?->name;
+ }
+
+ public function nestable(): bool
+ {
+ return false;
+ }
+
+ public function view(BlockRenderMode $mode): ?string
+ {
+ return null;
+ }
+
+ public function render(array $data, BlockRenderContext $context): string
+ {
+ if ($context->depth >= 1) {
+ Log::warning('email layout: saved block inside a saved block ignored', ['email_block_id' => $data['email_block_id'] ?? null]);
+
+ return '';
+ }
+
+ $block = EmailBlock::active()->find($data['email_block_id'] ?? null);
+
+ if ($block === null) {
+ Log::warning('email layout: saved block missing or inactive', ['email_block_id' => $data['email_block_id'] ?? null]);
+
+ return '';
+ }
+
+ return $context->renderer->render($block->layout ?? [], $context->models, $context->depth + 1);
+ }
+}
diff --git a/src/Blocks/Types/TwoColumnBlock.php b/src/Blocks/Types/TwoColumnBlock.php
new file mode 100644
index 0000000..8e27694
--- /dev/null
+++ b/src/Blocks/Types/TwoColumnBlock.php
@@ -0,0 +1,66 @@
+label('Left image'),
+ TextInput::make('left_image_alt')
+ ->label('Left image description (alt text)')
+ ->maxLength(150)
+ ->requiredWith('left_image'),
+ self::richEditor('left_content')
+ ->label('Left text')
+ ->required(),
+ self::imageUpload('right_image')
+ ->label('Right image'),
+ TextInput::make('right_image_alt')
+ ->label('Right image description (alt text)')
+ ->maxLength(150)
+ ->requiredWith('right_image'),
+ self::richEditor('right_content')
+ ->label('Right text')
+ ->required(),
+ ];
+ }
+
+ public function summary(array $data): ?string
+ {
+ return self::textSummary($data['left_content'] ?? null);
+ }
+
+ protected function tokenFields(): array
+ {
+ return ['left_content', 'right_content'];
+ }
+
+ protected function imageFields(): array
+ {
+ return ['left_image', 'right_image'];
+ }
+}
diff --git a/src/EmailTemplatesPlugin.php b/src/EmailTemplatesPlugin.php
index 2acfe5b..0e54e5b 100644
--- a/src/EmailTemplatesPlugin.php
+++ b/src/EmailTemplatesPlugin.php
@@ -8,6 +8,7 @@
use Filament\Panel;
use Filament\Support\Concerns\EvaluatesClosures;
use Illuminate\Support\Str;
+use Visualbuilder\EmailTemplates\Resources\EmailBlockResource;
use Visualbuilder\EmailTemplates\Resources\EmailTemplateResource;
use Visualbuilder\EmailTemplates\Resources\EmailTemplateThemeResource;
@@ -168,6 +169,7 @@ public function register(Panel $panel): void
$panel->resources([
EmailTemplateResource::class,
EmailTemplateThemeResource::class,
+ EmailBlockResource::class,
]);
}
diff --git a/src/EmailTemplatesServiceProvider.php b/src/EmailTemplatesServiceProvider.php
index 63463e3..3f13a83 100644
--- a/src/EmailTemplatesServiceProvider.php
+++ b/src/EmailTemplatesServiceProvider.php
@@ -8,6 +8,7 @@
use Spatie\LaravelPackageTools\PackageServiceProvider;
use Visualbuilder\EmailTemplates\Commands\InstallCommand;
use Visualbuilder\EmailTemplates\Commands\WrapContentTokensCommand;
+use Visualbuilder\EmailTemplates\Blocks\EmailBlockRegistry;
use Visualbuilder\EmailTemplates\Contracts\CreateMailableInterface;
use Visualbuilder\EmailTemplates\Contracts\FormHelperInterface;
@@ -18,7 +19,12 @@ class EmailTemplatesServiceProvider extends PackageServiceProvider
{
public function configurePackage(Package $package): void
{
- $migrations = ['create_email_templates_themes_table', 'create_email_templates_table'];
+ $migrations = [
+ 'create_email_templates_themes_table',
+ 'create_email_templates_table',
+ 'add_layout_to_email_templates_table',
+ 'create_email_blocks_table',
+ ];
if (config('filament-email-templates.multitenancy.enabled')) {
$migrations[] = 'add_tenant_to_email_templates_tables';
@@ -47,6 +53,7 @@ public function packageRegistered(): void
$this->app->singleton(CreateMailableInterface::class, CreateMailableHelper::class);
$this->app->singleton(FormHelperInterface::class, FormHelper::class);
$this->app->singleton(TokenRegistry::class);
+ $this->app->singleton(EmailBlockRegistry::class);
$this->app->register(EmailTemplatesEventServiceProvider::class);
// Add the binding for TokenReplacementInterface
diff --git a/src/Enums/BlockRenderMode.php b/src/Enums/BlockRenderMode.php
new file mode 100644
index 0000000..631d1cb
--- /dev/null
+++ b/src/Enums/BlockRenderMode.php
@@ -0,0 +1,14 @@
+ ['type' => ..., 'data' => [...]]])
+ * to email table rows. Used by the send path, the template preview and the
+ * Builder block previews, so all three produce the same markup.
+ */
+class EmailLayoutRenderer
+{
+ /** @var array */
+ protected array $theme = [];
+
+ public function __construct(protected EmailBlockRegistry $registry) {}
+
+ /**
+ * @param array $colours
+ */
+ public function withTheme(array $colours): static
+ {
+ $clone = clone $this;
+ $clone->theme = $colours;
+
+ return $clone;
+ }
+
+ /**
+ * @param array $layout
+ */
+ public function render(array $layout, object|array|null $models, int $depth = 0): string
+ {
+ $html = '';
+
+ foreach (array_values($layout) as $item) {
+ if (! is_array($item) || ! is_string($item['type'] ?? null) || ! is_array($item['data'] ?? null)) {
+ continue;
+ }
+
+ $html .= $this->renderBlock($item['type'], $item['data'], $models, $depth);
+ }
+
+ return $html;
+ }
+
+ /**
+ * @param array $data
+ */
+ public function renderBlock(string $type, array $data, object|array|null $models, int $depth = 0): string
+ {
+ $definition = $this->registry->get($type);
+
+ if ($definition === null) {
+ Log::warning('email layout: unknown block type', ['type' => $type]);
+
+ return '';
+ }
+
+ return $definition->render($data, new BlockRenderContext($this, $this->theme, $models, BlockRenderMode::Email, $depth));
+ }
+}
diff --git a/src/Models/EmailBlock.php b/src/Models/EmailBlock.php
new file mode 100644
index 0000000..7ac27dc
--- /dev/null
+++ b/src/Models/EmailBlock.php
@@ -0,0 +1,63 @@
+ 'array',
+ 'is_active' => 'boolean',
+ ];
+
+ public function __construct(array $attributes = [])
+ {
+ parent::__construct($attributes);
+ $this->table = config('filament-email-templates.blocks_table_name', 'vb_email_blocks');
+ }
+
+ public function scopeActive(Builder $query): Builder
+ {
+ return $query->where('is_active', true);
+ }
+
+ /**
+ * Base64 HTML of this block rendered inside the default theme, for the
+ * preview iframe.
+ */
+ public function getBase64EmailPreviewData(): string
+ {
+ $theme = EmailTemplateTheme::query()->where('is_default', true)->firstOrFail();
+ $models = EmailTemplate::createEmailPreviewData();
+ $data = [
+ 'title' => $this->name,
+ 'preHeaderText' => '',
+ 'logo' => (new EmailTemplate)->logo,
+ 'theme' => $theme->colours,
+ 'colours' => $theme->colours,
+ 'blocks' => app(EmailLayoutRenderer::class)->withTheme($theme->colours)->render($this->layout ?? [], $models),
+ ];
+
+ return base64_encode(view(config('filament-email-templates.template_view_path').'.layout_preview', ['data' => $data])->render());
+ }
+}
diff --git a/src/Models/EmailTemplate.php b/src/Models/EmailTemplate.php
index 0f0a62a..323ac2e 100644
--- a/src/Models/EmailTemplate.php
+++ b/src/Models/EmailTemplate.php
@@ -18,6 +18,7 @@
use Illuminate\Support\Str;
use Visualbuilder\EmailTemplates\Database\Factories\EmailTemplateFactory;
use Visualbuilder\EmailTemplates\Facades\TokenHelper;
+use Visualbuilder\EmailTemplates\Layout\EmailLayoutRenderer;
/**
* @property int $id
@@ -32,6 +33,7 @@
* @property string $preheader
* @property string $language
* @property string $content
+ * @property array|null $layout
* @property string $created_at
* @property string $updated_at
* @property string $deleted_at
@@ -54,6 +56,7 @@ class EmailTemplate extends Model implements HasMedia
'title',
'preheader',
'content',
+ 'layout',
'language',
'logo',
'cc',
@@ -71,6 +74,7 @@ class EmailTemplate extends Model implements HasMedia
'from' => 'array',
'cc' => 'array',
'bcc' => 'array',
+ 'layout' => 'array',
];
/**
* @var string[]
@@ -356,7 +360,7 @@ public function theme()
public function getBase64EmailPreviewData()
{
$data = $this->getEmailPreviewData();
- $content = view($this->view_path, ['data' => $data])->render();
+ $content = view($this->renderViewPath(), ['data' => $data])->render();
return base64_encode($content);
}
@@ -378,7 +382,7 @@ public function getEmailPreviewData()
return $content;
};
- return [
+ $data = [
'user' => $models->user ?? null,
'content' => TokenHelper::replace($applyOverrides($this->content ?? ''), $models),
'subject' => TokenHelper::replace($applyOverrides($this->subject ?? ''), $models),
@@ -387,6 +391,32 @@ public function getEmailPreviewData()
'theme' => $this->theme->colours,
'logo' => $this->logo,
];
+
+ if ($this->usesLayout()) {
+ $data['blocks'] = app(EmailLayoutRenderer::class)->withTheme($this->theme->colours ?? [])->render($this->layout, $models);
+ }
+
+ return $data;
+ }
+
+ /**
+ * True when the body is built from blocks (layout) rather than the
+ * single content field.
+ */
+ public function usesLayout(): bool
+ {
+ return is_array($this->layout) && $this->layout !== [];
+ }
+
+ /**
+ * The Blade view the email renders with: the block layout view in block
+ * mode, the template's chosen view otherwise.
+ */
+ public function renderViewPath(): string
+ {
+ return $this->usesLayout()
+ ? config('filament-email-templates.template_view_path').'.layout'
+ : $this->view_path;
}
/**
diff --git a/src/Resources/EmailBlockResource.php b/src/Resources/EmailBlockResource.php
new file mode 100644
index 0000000..4333246
--- /dev/null
+++ b/src/Resources/EmailBlockResource.php
@@ -0,0 +1,148 @@
+shouldRegisterNavigation();
+ }
+
+ public static function getNavigationIcon(): ?string
+ {
+ return config('filament-email-templates.navigation.blocks.icon');
+ }
+
+ public static function getNavigationGroup(): ?string
+ {
+ return EmailTemplatesPlugin::get()->getNavigationGroup();
+ }
+
+ public static function getNavigationSort(): ?int
+ {
+ return config('filament-email-templates.navigation.blocks.sort');
+ }
+
+ public static function getModelLabel(): string
+ {
+ return __(config('filament-email-templates.navigation.blocks.label', 'Email Blocks'));
+ }
+
+ public static function getPluralModelLabel(): string
+ {
+ return __(config('filament-email-templates.navigation.blocks.label', 'Email Blocks'));
+ }
+
+ public static function getCluster(): ?string
+ {
+ return static::$cluster ?? (config('filament-email-templates.navigation.blocks.cluster') ?: null);
+ }
+
+ public static function getSubNavigationPosition(): SubNavigationPosition
+ {
+ return config('filament-email-templates.navigation.blocks.position', SubNavigationPosition::Top);
+ }
+
+ public static function form(Schema $schema): Schema
+ {
+ return $schema->schema([
+ Section::make()
+ ->columnSpanFull()
+ ->schema([
+ TextInput::make('name')
+ ->label(__('Name'))
+ ->required()
+ ->maxLength(120)
+ ->unique(ignoreRecord: true),
+ TextInput::make('description')
+ ->label(__('Description'))
+ ->maxLength(255),
+ Toggle::make('is_active')
+ ->label(__('Available in the composer'))
+ ->default(true),
+ EmailLayoutBuilder::make('layout', allowSavedBlocks: false),
+ ]),
+ ]);
+ }
+
+ public static function table(Table $table): Table
+ {
+ return $table
+ ->columns([
+ TextColumn::make('name')
+ ->label(__('Name'))
+ ->searchable()
+ ->sortable(),
+ TextColumn::make('description')
+ ->label(__('Description'))
+ ->limit(60),
+ IconColumn::make('is_active')
+ ->label(__('Available'))
+ ->boolean(),
+ TextColumn::make('updated_at')
+ ->label(__('Updated'))
+ ->since()
+ ->sortable(),
+ ])
+ ->filters([
+ TernaryFilter::make('is_active')
+ ->label(__('Available in the composer')),
+ TrashedFilter::make(),
+ ])
+ ->recordActions([
+ EditAction::make(),
+ DeleteAction::make()
+ ->modalDescription(__('Templates using this block will send without it.')),
+ RestoreAction::make(),
+ ])
+ ->toolbarActions([
+ DeleteBulkAction::make()
+ ->modalDescription(__('Templates using these blocks will send without them.')),
+ ]);
+ }
+
+ public static function getEloquentQuery(): Builder
+ {
+ return parent::getEloquentQuery()->withoutGlobalScopes([SoftDeletingScope::class]);
+ }
+
+ public static function getPages(): array
+ {
+ return [
+ 'index' => Pages\ListEmailBlocks::route('/'),
+ 'create' => Pages\CreateEmailBlock::route('/create'),
+ 'edit' => Pages\EditEmailBlock::route('/{record}/edit'),
+ ];
+ }
+}
diff --git a/src/Resources/EmailBlockResource/Pages/CreateEmailBlock.php b/src/Resources/EmailBlockResource/Pages/CreateEmailBlock.php
new file mode 100644
index 0000000..93be7f5
--- /dev/null
+++ b/src/Resources/EmailBlockResource/Pages/CreateEmailBlock.php
@@ -0,0 +1,13 @@
+label(__('Preview'))
+ ->slideOver()
+ ->modalSubmitAction(false)
+ ->modalContent(fn (EmailBlock $record): View => view('vb-email-templates::forms.components.iframe', ['record' => $record])),
+ DeleteAction::make()
+ ->modalDescription(__('Templates using this block will send without it.')),
+ RestoreAction::make(),
+ ];
+ }
+}
diff --git a/src/Resources/EmailBlockResource/Pages/ListEmailBlocks.php b/src/Resources/EmailBlockResource/Pages/ListEmailBlocks.php
new file mode 100644
index 0000000..fe699f8
--- /dev/null
+++ b/src/Resources/EmailBlockResource/Pages/ListEmailBlocks.php
@@ -0,0 +1,21 @@
+visible(fn () => EmailTemplatesPlugin::get()->hasScreenshotCapture())
->action(function (EmailTemplate $record): void {
$data = $record->getEmailPreviewData();
- $html = view($record->view_path, ['data' => $data])->render();
+ $html = view($record->renderViewPath(), ['data' => $data])->render();
$callback = EmailTemplatesPlugin::get()->getScreenshotCaptureCallback();
$result = $callback($html);
@@ -210,7 +214,7 @@ public static function table(Table $table): Table
foreach ($records as $record) {
try {
$data = $record->getEmailPreviewData();
- $html = view($record->view_path, ['data' => $data])->render();
+ $html = view($record->renderViewPath(), ['data' => $data])->render();
\Visualbuilder\EmailTemplates\Jobs\CaptureEmailScreenshot::dispatch($record, $html);
$dispatched++;
@@ -321,13 +325,40 @@ public static function form(Schema $schema): Schema
->hint(__('vb-email-templates::email-templates.form-fields-labels.title-hint'))
->maxLength(191),
+ Toggle::make('use_layout')
+ ->label(__('Build the body from blocks'))
+ ->live()
+ ->dehydrated(false)
+ ->afterStateHydrated(fn (Toggle $component, ?Model $record) => $component->state($record instanceof EmailTemplate && $record->usesLayout()))
+ ->hidden(fn (?Model $record): bool => (bool) ($record?->is_quote_template ?? false))
+ ->afterStateUpdated(function (bool $state, Get $get, Set $set): void {
+ // Switching on copies the current body into a first Text block.
+ if ($state && blank($get('layout'))) {
+ $set('layout', [
+ (string) Str::uuid() => ['type' => 'rich_text', 'data' => ['content' => (string) $get('content')]],
+ ]);
+ }
+ }),
+
TinyEditor::make('content')
->label(__('vb-email-templates::email-templates.form-fields-labels.content'))
->profile('email-template')
->setCustomConfigs(fn () => [
'vbtokens_list' => app(\Visualbuilder\EmailTemplates\TokenRegistry::class)->menu(),
])
- ->default("Dear ##user.first_name##, "),
+ ->default("Dear ##user.first_name##, ")
+ ->visible(fn (Get $get): bool => ! $get('use_layout')),
+
+ Text::make(__('Header: the theme logo and the title above are always shown.'))
+ ->key('layout_header')
+ ->visible(fn (Get $get): bool => (bool) $get('use_layout')),
+
+ EmailLayoutBuilder::make('layout')
+ ->visible(fn (Get $get): bool => (bool) $get('use_layout')),
+
+ Text::make(__('Footer: the "Need help?" block, footer links and address are always shown. Campaign sends also add an unsubscribe link, which cannot be removed.'))
+ ->key('layout_footer')
+ ->visible(fn (Get $get): bool => (bool) $get('use_layout')),
Radio::make('logo_type')
->label(__('vb-email-templates::email-templates.form-fields-labels.logo-type'))
diff --git a/src/Resources/EmailTemplateResource/Pages/CreateEmailTemplate.php b/src/Resources/EmailTemplateResource/Pages/CreateEmailTemplate.php
index f27b35a..1228621 100644
--- a/src/Resources/EmailTemplateResource/Pages/CreateEmailTemplate.php
+++ b/src/Resources/EmailTemplateResource/Pages/CreateEmailTemplate.php
@@ -10,6 +10,15 @@ class CreateEmailTemplate extends CreateRecord
{
protected static string $resource = EmailTemplateResource::class;
+ protected function mutateFormDataBeforeCreate(array $data): array
+ {
+ if (! ($this->data['use_layout'] ?? false)) {
+ $data['layout'] = null;
+ }
+
+ return $data;
+ }
+
protected function handleRecordCreation(array $data): Model
{
$emailTemplateResource = new EmailTemplateResource();
diff --git a/src/Resources/EmailTemplateResource/Pages/EditEmailTemplate.php b/src/Resources/EmailTemplateResource/Pages/EditEmailTemplate.php
index 03f5102..593cd61 100644
--- a/src/Resources/EmailTemplateResource/Pages/EditEmailTemplate.php
+++ b/src/Resources/EmailTemplateResource/Pages/EditEmailTemplate.php
@@ -46,6 +46,16 @@ protected function mutateFormDataBeforeFill(array $data): array
return $data;
}
+ protected function mutateFormDataBeforeSave(array $data): array
+ {
+ // Block mode off stores no layout; content is kept, so switching back restores the old body.
+ if (! ($this->data['use_layout'] ?? false)) {
+ $data['layout'] = null;
+ }
+
+ return $data;
+ }
+
protected function handleRecordUpdate(Model $record, array $data): Model
{
$emailTemplateResource = new EmailTemplateResource();
diff --git a/src/Resources/Schemas/EmailLayoutBuilder.php b/src/Resources/Schemas/EmailLayoutBuilder.php
new file mode 100644
index 0000000..60503b9
--- /dev/null
+++ b/src/Resources/Schemas/EmailLayoutBuilder.php
@@ -0,0 +1,42 @@
+all() : $registry->nestable();
+
+ return Builder::make($name)
+ ->label('Email body')
+ ->addActionLabel('Add block')
+ ->blockNumbers(false)
+ ->blockPreviews()
+ ->reorderableWithButtons()
+ ->collapsible()
+ ->minItems(1)
+ ->blocks(collect($definitions)->map(
+ fn (EmailBlockDefinition $definition): EmailBuilderBlock => EmailBuilderBlock::make($definition->name())
+ ->label(fn (?array $state): string => filled($summary = $definition->summary($state ?? []))
+ ? $definition->label().': '.Str::limit($summary, 50)
+ : $definition->label())
+ ->icon($definition->icon())
+ ->preview('vb-email-templates::forms.block-preview')
+ ->schema($definition->schema()),
+ )->values()->all())
+ ->columnSpanFull();
+ }
+}
diff --git a/src/Traits/BuildGenericEmail.php b/src/Traits/BuildGenericEmail.php
index e793d86..df1541f 100644
--- a/src/Traits/BuildGenericEmail.php
+++ b/src/Traits/BuildGenericEmail.php
@@ -5,6 +5,7 @@
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
use Visualbuilder\EmailTemplates\Facades\TokenHelper;
+use Visualbuilder\EmailTemplates\Layout\EmailLayoutRenderer;
use Visualbuilder\EmailTemplates\Models\EmailTemplate;
trait BuildGenericEmail
@@ -44,6 +45,14 @@ public function build()
'logo' => $this->emailTemplate->logo,
];
+ // Block mode: the body is the rendered layout; content is still set but unused by layout.blade.php.
+ if ($this->emailTemplate->usesLayout()) {
+ $data['blocks'] = app(EmailLayoutRenderer::class)
+ ->withTheme($this->emailTemplate->theme->colours ?? [])
+ ->render($this->emailTemplate->layout, $this);
+ $data['unsubscribeUrl'] = property_exists($this, 'unsubscribeUrl') ? ($this->unsubscribeUrl ?? null) : null;
+ }
+
if(is_array($this->emailTemplate->cc)&&count($this->emailTemplate->cc)){
$this->cc($this->emailTemplate->cc);
};
@@ -53,7 +62,7 @@ public function build()
};
return $this->from($this->emailTemplate->from['email'], $this->emailTemplate->from['name'])
- ->view($this->emailTemplate->view_path)
+ ->view($this->emailTemplate->renderViewPath())
->subject(TokenHelper::replace($this->emailTemplate->subject, $this))
->to($this->sendTo)
->with(['data' => $data]);
diff --git a/tests/AdminPanelProvider.php b/tests/AdminPanelProvider.php
index f1dd503..f5d3aa4 100644
--- a/tests/AdminPanelProvider.php
+++ b/tests/AdminPanelProvider.php
@@ -16,6 +16,7 @@
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Visualbuilder\EmailTemplates\EmailTemplatesPlugin;
+use Visualbuilder\EmailTemplates\Resources\EmailBlockResource;
use Visualbuilder\EmailTemplates\Resources\EmailTemplateResource;
use Visualbuilder\EmailTemplates\Resources\EmailTemplateThemeResource;
@@ -34,6 +35,7 @@ public function panel(Panel $panel): Panel
->resources([
EmailTemplateResource::class,
EmailTemplateThemeResource::class,
+ EmailBlockResource::class,
])
->middleware([
EncryptCookies::class,
diff --git a/tests/EmailBlockRegistryTest.php b/tests/EmailBlockRegistryTest.php
new file mode 100644
index 0000000..af6e0d6
--- /dev/null
+++ b/tests/EmailBlockRegistryTest.php
@@ -0,0 +1,75 @@
+all();
+
+ expect(array_keys($all))->toBe(['rich_text', 'hero', 'two_column', 'button', 'image', 'divider', 'saved_block'])
+ ->and(array_map(fn ($definition) => $definition::class, array_values($all)))->toBe(EmailBlockRegistry::DEFAULT_TYPES)
+ ->and(EmailBlockRegistry::DEFAULT_TYPES)->toBe([
+ RichTextBlock::class,
+ HeroBlock::class,
+ TwoColumnBlock::class,
+ ButtonBlock::class,
+ ImageBlock::class,
+ DividerBlock::class,
+ SavedBlock::class,
+ ]);
+});
+
+it('falls back to the default types when the config key is empty', function () {
+ config(['filament-email-templates.block_types' => []]);
+
+ expect(array_keys((new EmailBlockRegistry)->all()))->toHaveCount(7);
+});
+
+it('excludes saved blocks from the nestable types', function () {
+ $nestable = app(EmailBlockRegistry::class)->nestable();
+
+ expect($nestable)->not->toHaveKey('saved_block')
+ ->and(array_keys($nestable))->toBe(['rich_text', 'hero', 'two_column', 'button', 'image', 'divider']);
+});
+
+it('adds a host block registered at runtime last and renders it', function () {
+ $registry = app(EmailBlockRegistry::class);
+ $registry->all();
+
+ $registry->register(QuoteBlock::class);
+
+ expect(array_key_last($registry->all()))->toBe('quote')
+ ->and(app(EmailLayoutRenderer::class)->render([['type' => 'quote', 'data' => []]], null))
+ ->toBe('| quote block | ');
+});
+
+it('adds a host block listed in the block_types config', function () {
+ config(['filament-email-templates.block_types' => [...EmailBlockRegistry::DEFAULT_TYPES, QuoteBlock::class]]);
+
+ expect(array_keys((new EmailBlockRegistry)->all()))->toContain('quote')
+ ->and(array_key_last((new EmailBlockRegistry)->all()))->toBe('quote');
+});
+
+it('replaces a package block in place when a later class has the same name', function () {
+ $registry = (new EmailBlockRegistry)->register(ReplacementHeroBlock::class);
+
+ expect(array_keys($registry->all()))->toBe(['rich_text', 'hero', 'two_column', 'button', 'image', 'divider', 'saved_block'])
+ ->and($registry->get('hero'))->toBeInstanceOf(ReplacementHeroBlock::class);
+});
+
+it('rejects a class that does not implement the contract', function () {
+ (new EmailBlockRegistry)->register(stdClass::class)->all();
+})->throws(InvalidArgumentException::class, 'stdClass must implement EmailBlockDefinition');
+
+it('returns null for an unknown block name', function () {
+ expect(app(EmailBlockRegistry::class)->get('nope'))->toBeNull();
+});
diff --git a/tests/EmailBlockResourceTest.php b/tests/EmailBlockResourceTest.php
new file mode 100644
index 0000000..67f4e12
--- /dev/null
+++ b/tests/EmailBlockResourceTest.php
@@ -0,0 +1,181 @@
+ ['type' => 'rich_text', 'data' => ['content' => "{$text} "]]];
+}
+
+it('can access the block library list page', function () {
+ get(EmailBlockResource::getUrl('index'))->assertSuccessful();
+});
+
+it('lists blocks', function () {
+ $blocks = collect([
+ EmailBlock::create(['name' => 'Signature', 'layout' => textLayout('Sig')]),
+ EmailBlock::create(['name' => 'Promo', 'layout' => textLayout('Promo'), 'is_active' => false]),
+ ]);
+
+ livewire(ListEmailBlocks::class)->assertCanSeeTableRecords($blocks);
+});
+
+it('searches blocks by name', function () {
+ $signature = EmailBlock::create(['name' => 'Signature', 'layout' => textLayout('Sig')]);
+ $promo = EmailBlock::create(['name' => 'Promo', 'layout' => textLayout('Promo')]);
+
+ livewire(ListEmailBlocks::class)
+ ->searchTable('Signature')
+ ->assertCanSeeTableRecords([$signature])
+ ->assertCanNotSeeTableRecords([$promo]);
+});
+
+it('sorts blocks by name', function () {
+ EmailBlock::create(['name' => 'Promo', 'layout' => textLayout('Promo')]);
+ EmailBlock::create(['name' => 'Signature', 'layout' => textLayout('Sig')]);
+
+ $ascending = EmailBlock::query()->orderBy('name')->get();
+ $descending = EmailBlock::query()->orderBy('name', 'desc')->get();
+
+ livewire(ListEmailBlocks::class)
+ ->sortTable('name')
+ ->assertCanSeeTableRecords($ascending, inOrder: true)
+ ->sortTable('name', 'desc')
+ ->assertCanSeeTableRecords($descending, inOrder: true);
+});
+
+it('filters blocks by availability', function () {
+ $active = EmailBlock::create(['name' => 'Signature', 'layout' => textLayout('Sig')]);
+ $inactive = EmailBlock::create(['name' => 'Promo', 'layout' => textLayout('Promo'), 'is_active' => false]);
+
+ livewire(ListEmailBlocks::class)
+ ->filterTable('is_active', true)
+ ->assertCanSeeTableRecords([$active])
+ ->assertCanNotSeeTableRecords([$inactive])
+ ->filterTable('is_active', false)
+ ->assertCanSeeTableRecords([$inactive])
+ ->assertCanNotSeeTableRecords([$active]);
+});
+
+it('filters trashed blocks', function () {
+ $kept = EmailBlock::create(['name' => 'Signature', 'layout' => textLayout('Sig')]);
+ $trashed = EmailBlock::create(['name' => 'Promo', 'layout' => textLayout('Promo')]);
+ $trashed->delete();
+
+ livewire(ListEmailBlocks::class)
+ ->assertCanSeeTableRecords([$kept])
+ ->assertCanNotSeeTableRecords([$trashed])
+ ->filterTable('trashed', false)
+ ->assertCanSeeTableRecords([$trashed])
+ ->assertCanNotSeeTableRecords([$kept])
+ ->filterTable('trashed', true)
+ ->assertCanSeeTableRecords([$kept, $trashed]);
+});
+
+it('can access the create and edit pages', function () {
+ $block = EmailBlock::create(['name' => 'Signature', 'layout' => textLayout('Sig')]);
+
+ get(EmailBlockResource::getUrl('create'))->assertSuccessful();
+ get(EmailBlockResource::getUrl('edit', ['record' => $block]))->assertSuccessful();
+});
+
+it('creates a block', function () {
+ livewire(CreateEmailBlock::class)
+ ->fillForm([
+ 'name' => 'Signature',
+ 'description' => 'Team sign-off',
+ 'layout' => textLayout('Kind regards'),
+ ])
+ ->call('create')
+ ->assertHasNoFormErrors();
+
+ $block = EmailBlock::where('name', 'Signature')->first();
+ expect($block)->not->toBeNull()
+ ->and($block->is_active)->toBeTrue()
+ ->and($block->description)->toBe('Team sign-off')
+ ->and(array_values($block->layout)[0]['data']['content'])->toBe('Kind regards ');
+});
+
+it('does not offer saved blocks inside the block library form', function () {
+ livewire(CreateEmailBlock::class)
+ ->assertFormComponentExists('layout', fn (Builder $builder): bool => $builder->getBlock('saved_block') === null
+ && $builder->getBlock('rich_text') !== null);
+});
+
+it('requires a unique name and at least one block', function () {
+ EmailBlock::create(['name' => 'Signature', 'layout' => textLayout('Sig')]);
+
+ livewire(CreateEmailBlock::class)
+ ->fillForm(['name' => 'Signature', 'layout' => []])
+ ->call('create')
+ ->assertHasFormErrors(['name' => 'unique', 'layout' => 'min']);
+});
+
+it('edits a block keeping its own name valid', function () {
+ $block = EmailBlock::create(['name' => 'Signature', 'layout' => textLayout('Old')]);
+
+ livewire(EditEmailBlock::class, ['record' => $block->getRouteKey()])
+ ->fillForm(['is_active' => false])
+ ->set('data.layout', textLayout('New'))
+ ->call('save')
+ ->assertHasNoFormErrors();
+
+ $block->refresh();
+ expect($block->is_active)->toBeFalse()
+ ->and($block->layout)->toHaveCount(1)
+ ->and(array_values($block->layout)[0]['data']['content'])->toBe('New ');
+});
+
+it('soft deletes and restores a block', function () {
+ $block = EmailBlock::create(['name' => 'Signature', 'layout' => textLayout('Sig')]);
+
+ livewire(EditEmailBlock::class, ['record' => $block->getRouteKey()])
+ ->assertActionExists(DeleteAction::class)
+ ->callAction(DeleteAction::class);
+
+ $this->assertSoftDeleted($block);
+
+ livewire(ListEmailBlocks::class)
+ ->callAction(TestAction::make(RestoreAction::class)->table($block));
+
+ expect($block->fresh()->trashed())->toBeFalse();
+});
+
+it('deletes a block from the table and warns that templates send without it', function () {
+ $block = EmailBlock::create(['name' => 'Signature', 'layout' => textLayout('Sig')]);
+
+ livewire(ListEmailBlocks::class)
+ ->assertActionExists(
+ TestAction::make(DeleteAction::class)->table($block),
+ fn (DeleteAction $action): bool => $action->getModalDescription() === 'Templates using this block will send without it.',
+ )
+ ->callAction(TestAction::make(DeleteAction::class)->table($block));
+
+ $this->assertSoftDeleted($block);
+});
+
+it('previews a block inside the default theme', function () {
+ $this->makeTheme();
+ $block = EmailBlock::create(['name' => 'Signature', 'layout' => textLayout('Preview me ##user.name##')]);
+
+ $html = base64_decode($block->getBase64EmailPreviewData());
+
+ expect($html)->toContain('Preview me')
+ ->toContain('Signature')
+ ->toContain('');
+
+ livewire(EditEmailBlock::class, ['record' => $block->getRouteKey()])
+ ->callAction('preview')
+ ->assertSuccessful();
+});
diff --git a/tests/EmailLayoutBuilderTest.php b/tests/EmailLayoutBuilderTest.php
new file mode 100644
index 0000000..d856c92
--- /dev/null
+++ b/tests/EmailLayoutBuilderTest.php
@@ -0,0 +1,100 @@
+getDefaultChildComponents();
+}
+
+/** The document a block preview iframe loads, decoded from its srcdoc attribute. */
+function previewDocument(string $html): string
+{
+ expect(preg_match('/srcdoc="([^"]*)"/', $html, $match))->toBe(1);
+
+ return html_entity_decode($match[1], ENT_QUOTES | ENT_HTML5);
+}
+
+it('has one builder block per registered block type', function () {
+ $blocks = builderBlocks(EmailLayoutBuilder::make());
+
+ expect(array_map(fn (EmailBuilderBlock $block) => $block->getName(), $blocks))
+ ->toBe(array_keys(app(EmailBlockRegistry::class)->all()));
+});
+
+it('leaves saved blocks out when saved blocks are not allowed', function () {
+ $names = array_map(fn ($block) => $block->getName(), builderBlocks(EmailLayoutBuilder::make('layout', allowSavedBlocks: false)));
+
+ expect($names)->not->toContain('saved_block')->toContain('rich_text');
+});
+
+it('offers host block types added to the registry', function () {
+ app(EmailBlockRegistry::class)->register(QuoteBlock::class);
+
+ $names = array_map(fn ($block) => $block->getName(), builderBlocks(EmailLayoutBuilder::make()));
+
+ expect(end($names))->toBe('quote');
+});
+
+it('labels a block with its summary and plain in the picker', function () {
+ $hero = collect(builderBlocks(EmailLayoutBuilder::make()))->first(fn ($block) => $block->getName() === 'hero');
+
+ expect($hero->getLabel(['heading' => 'Spring offer']))->toBe('Hero: Spring offer')
+ ->and($hero->getLabel(['heading' => '']))->toBe('Hero')
+ ->and($hero->getLabel(null))->toBe('Hero')
+ ->and($hero->getLabel(['heading' => str_repeat('x', 80)]))->toBe('Hero: '.str_repeat('x', 50).'...');
+});
+
+it('previews a block through the email renderer with tokens left in place', function () {
+ $this->makeTheme();
+
+ $html = EmailBuilderBlock::make('button')
+ ->preview('vb-email-templates::forms.block-preview')
+ ->renderPreview(['label' => 'Book ##user.name##', 'url' => 'https://example.com', 'align' => 'center'])
+ ->render();
+
+ expect(previewDocument($html))->toContain('Book ##user.name##')
+ ->toContain('')
+ ->toContain('v:roundrect')
+ ->toContain('fillcolor="#FFEB3B"')
+ ->toContain('max-width: 600px; margin: 0 auto;');
+});
+
+it('previews a block without a default theme', function () {
+ $html = EmailBuilderBlock::make('rich_text')
+ ->preview('vb-email-templates::forms.block-preview')
+ ->renderPreview(['content' => ' No theme yet '])
+ ->render();
+
+ expect(previewDocument($html))->toContain('No theme yet ');
+});
+
+it('isolates block preview html in a sandboxed iframe', function () {
+ $html = EmailBuilderBlock::make('rich_text')
+ ->preview('vb-email-templates::forms.block-preview')
+ ->renderPreview(['content' => 'Hi '])
+ ->render();
+
+ expect($html)->not->toContain('')
+ ->toContain('onerror="alert(2)"');
+});
+
+it('accepts only raster images for block image uploads', function () {
+ $upload = collect((new ImageBlock)->schema())->first(fn ($component) => $component instanceof FileUpload);
+
+ expect($upload->getAcceptedFileTypes())->toBe(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
+});
diff --git a/tests/EmailLayoutRendererTest.php b/tests/EmailLayoutRendererTest.php
new file mode 100644
index 0000000..f9fb7b6
--- /dev/null
+++ b/tests/EmailLayoutRendererTest.php
@@ -0,0 +1,269 @@
+ '#1E88E5',
+ 'body_bg_color' => '#f4f4f4',
+ 'content_bg_color' => '#FFFFFB',
+ 'footer_bg_color' => '#34495E',
+ 'callout_bg_color' => '#FFC107',
+ 'button_bg_color' => '#FFEB3B',
+ 'body_color' => '#333333',
+ 'callout_color' => '#212121',
+ 'button_color' => '#2A2A11',
+ 'anchor_color' => '#1E88E5',
+ ];
+}
+
+function layoutRenderer(): EmailLayoutRenderer
+{
+ return app(EmailLayoutRenderer::class)->withTheme(layoutTheme());
+}
+
+function layoutModels(string $name = 'Ada Lovelace'): object
+{
+ return (object) ['user' => User::factory()->make(['name' => $name])];
+}
+
+it('renders a text block as one row with raw rich text and tokens replaced', function () {
+ $html = layoutRenderer()->render([
+ 'a1' => ['type' => 'rich_text', 'data' => ['content' => 'Hello ##user.name## ']],
+ ], layoutModels());
+
+ expect($html)->toContain('')
+ ->toContain('Hello Ada Lovelace ')
+ ->toContain('bgcolor="#FFFFFB"')
+ ->toContain('background-color: #FFFFFB')
+ ->toContain('max-width: 600px')
+ ->toContain('', '', $html)))->toStartWith('')->toEndWith(' ');
+});
+
+it('leaves tokens unreplaced when there are no models (builder preview)', function () {
+ $html = layoutRenderer()->renderBlock('rich_text', ['content' => 'Hi ##user.name## '], null);
+
+ expect($html)->toContain('Hi ##user.name## ');
+});
+
+it('renders a hero with escaped plain fields, a public image URL and a button', function () {
+ Storage::fake('public', ['url' => 'https://cdn.test']);
+ config(['filament-email-templates.block_images_disk' => 'public']);
+
+ $html = layoutRenderer()->renderBlock('hero', [
+ 'image' => 'media/email-templates/blocks/spring.png',
+ 'image_alt' => 'Spring banner',
+ 'heading' => 'Spring offer for ##user.name##',
+ 'text' => "Line one\nLine two",
+ 'button_label' => 'Book now',
+ 'button_url' => 'https://example.com/book',
+ ], layoutModels());
+
+ expect($html)->toContain('')
+ ->toContain('Spring <script>alert(1)</script> offer for Ada Lovelace')
+ ->not->toContain(' | |