Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
120 changes: 120 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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])
<blockquote style="margin: 0;">{{ $block['quote'] }}</blockquote>
@include('vb-email-templates::email.parts._block_close')
```

Register the class in config (the list is also the order of the "Add block" menu):

```php
'block_types' => [
...\Visualbuilder\EmailTemplates\Blocks\EmailBlockRegistry::DEFAULT_TYPES,
\App\EmailBlocks\QuoteBlock::class,
],
```

or at runtime, e.g. in a service provider's `boot()`:

```php
app(\Visualbuilder\EmailTemplates\Blocks\EmailBlockRegistry::class)->register(\App\EmailBlocks\QuoteBlock::class);
```

A class whose `name()` matches a package block replaces it in the same menu position.

If your `_support_block.blade.php` override is a fragment without its own `<tr><td>` 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:
Expand Down
33 changes: 33 additions & 0 deletions config/filament-email-templates.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
30 changes: 30 additions & 0 deletions database/migrations/add_layout_to_email_templates_table.php.stub
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
$table = config('filament-email-templates.table_name');

if (! Schema::hasColumn($table, 'layout')) {
Schema::table($table, function (Blueprint $table) {
$table->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');
});
}
}
};
27 changes: 27 additions & 0 deletions database/migrations/create_email_blocks_table.php.stub
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create(config('filament-email-templates.blocks_table_name', 'vb_email_blocks'), function (Blueprint $table) {
$table->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'));
}
};
4 changes: 4 additions & 0 deletions resources/views/email/blocks/button.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<!-- BLOCK: BUTTON -->
@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')
13 changes: 13 additions & 0 deletions resources/views/email/blocks/divider.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- BLOCK: DIVIDER -->
@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'])
<table role="presentation" border="0" cellspacing="0" cellpadding="0" width="100%">
<tr>
<td bgcolor="{{ $cellBg }}" height="1" style="height: 1px; font-size: 1px; line-height: 1px; mso-line-height-rule: exactly; background-color: {{ $cellBg }}; border-top: {{ $showLine ? '1px solid '.($block['line_color'] ?? '#e5e7eb') : '0' }};">&nbsp;</td>
</tr>
</table>
@include('vb-email-templates::email.parts._block_close')
14 changes: 14 additions & 0 deletions resources/views/email/blocks/hero.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!-- BLOCK: HERO -->
@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))
<img src="{{ $block['image'] }}" alt="{{ $block['image_alt'] ?? '' }}" width="{{ $imageWidth }}" style="display: block; width: 100%; max-width: {{ $imageWidth }}px; height: auto; border: 0; margin: 0 0 20px 0;">
@endif
<h2 style="margin: 0 0 10px 0; font-family: {{ config('filament-email-templates.font_family') }}; font-size: 28px; font-weight: 700; mso-line-height-rule: exactly; line-height: 34px; color: {{ $theme['body_color'] ?? '#333333' }};">{{ $block['heading'] ?? '' }}</h2>
@if(filled($block['text'] ?? null))
<p style="margin: 0 0 10px 0; mso-line-height-rule: exactly; line-height: 28px;">{!! nl2br(e($block['text'])) !!}</p>
@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')
9 changes: 9 additions & 0 deletions resources/views/email/blocks/image.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!-- BLOCK: IMAGE -->
@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))<a href="{{ $block['link_url'] }}" target="_blank" style="display: block;">@endif
<img src="{{ $block['image'] }}" alt="{{ $block['alt'] ?? '' }}" width="{{ $imageWidth }}" style="display: block; width: 100%; max-width: {{ $imageWidth }}px; height: auto; border: 0; margin: 0 auto;">
@if(filled($block['link_url'] ?? null))</a>@endif
@include('vb-email-templates::email.parts._block_close')
@endif
4 changes: 4 additions & 0 deletions resources/views/email/blocks/rich_text.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<!-- BLOCK: TEXT -->
@include('vb-email-templates::email.parts._block_open', ['theme' => $theme])
{!! $block['content'] ?? '' !!}
@include('vb-email-templates::email.parts._block_close')
30 changes: 30 additions & 0 deletions resources/views/email/blocks/two_column.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!-- BLOCK: TWO COLUMNS -->
@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'])
<div style="font-size: 0; text-align: left;">
<!--[if mso]><table role="presentation" border="0" cellspacing="0" cellpadding="0" width="100%"><tr><td width="{{ $column + 20 }}" valign="top"><![endif]-->
@foreach(['left', 'right'] as $side)
@if($side === 'right')
<!--[if mso]></td><td width="{{ $column + 20 }}" valign="top"><![endif]-->
@endif
<div style="display: inline-block; width: 100%; max-width: {{ $column + 20 }}px; vertical-align: top;">
<table role="presentation" border="0" cellspacing="0" cellpadding="0" width="100%">
<tr>
<td bgcolor="{{ $cellBg }}" valign="top" style="padding: 0 10px 10px 10px; background-color: {{ $cellBg }}; color: {{ $theme['body_color'] ?? '#333333' }}; font-family: {{ config('filament-email-templates.font_family') }}; font-size: 18px; font-weight: 400; mso-line-height-rule: exactly; line-height: 1.8; text-align: left; word-wrap: break-word; word-break: break-word; overflow-wrap: anywhere;">
@if(filled($block[$side.'_image'] ?? null))
<img src="{{ $block[$side.'_image'] }}" alt="{{ $block[$side.'_image_alt'] ?? '' }}" width="{{ $column }}" style="display: block; width: 100%; max-width: {{ $column }}px; height: auto; border: 0; margin: 0 0 10px 0;">
@endif
{!! $block[$side.'_content'] ?? '' !!}
</td>
</tr>
</table>
</div>
@endforeach
<!--[if mso]></td></tr></table><![endif]-->
</div>
@include('vb-email-templates::email.parts._block_close')
15 changes: 15 additions & 0 deletions resources/views/email/layout.blade.php
Original file line number Diff line number Diff line change
@@ -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')
24 changes: 24 additions & 0 deletions resources/views/email/layout_preview.blade.php
Original file line number Diff line number Diff line change
@@ -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

<div style="background-color: {{$data['theme']["body_bg_color"]}};">

@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')

</div>
Loading
Loading