spire / mail
Visual drag-and-drop email template editor for Laravel
Installs: 5
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/spire/mail
Requires
- php: ^8.2
- illuminate/support: ^11.0|^12.0
- spatie/mjml-php: ^1.2
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
README
A visual drag-and-drop email template editor for Laravel. Build beautiful, responsive email templates with an intuitive editor and send them using Laravel's mail system.
Features
- Drag-and-drop email editor with real-time preview
- 10+ content blocks (text, heading, image, button, divider, spacer, HTML, video, social icons, multi-column rows)
- MJML-powered responsive rendering
- Advanced tag system with formatters, conditionals, and inline fallbacks
- Global and template-specific tags with editor UI
- 9 built-in formatters (date, currency, uppercase, lowercase, capitalize, truncate, count, number, default)
- Conditional rendering with
{{#if}},{{else}},{{#unless}} - Template management with bulk actions
- Test email sending
- Asset upload and management
- Configurable route prefix and authorization
- Vue 3 components for custom integrations
Requirements
Backend
- PHP 8.2+
- Laravel 11 or 12
Frontend
- Node.js 18+
- Vue 3
- Inertia.js
Quick Start
# Install the Composer package composer require spire/mail # Run the install command php artisan spire-mail:install # Install the npm package npm install @sabrenski/spire-mail # Build your assets npm run build
Access the editor at /admin/mail (or your configured route prefix).
Installation
Backend (Composer)
composer require spire/mail
Frontend (NPM)
The Vue editor components are distributed as a separate npm package:
npm install @sabrenski/spire-mail
Peer Dependencies: Your project must have these packages installed:
| Package | Version | Description |
|---|---|---|
vue |
^3.0.0 | Vue.js framework |
@sabrenski/spire-ui-vue |
^0.2.0 | Spire UI component library |
@hugeicons/core-free-icons |
^2.0.0 | Icon library |
@inertiajs/vue3 |
^2.0.0 | Inertia.js Vue adapter |
Install Command
Run the install command to set up the package:
php artisan spire-mail:install
Options:
| Option | Description |
|---|---|
--publish-config |
Publish configuration file for customization |
--no-migrate |
Skip running migrations |
--force |
Overwrite existing files |
Configuration
Route Prefix
By default, routes are registered at /admin/mail. Customize this with an environment variable:
SPIRE_MAIL_PREFIX=email-admin
Routes will then be available at /email-admin.
Environment Variables
| Variable | Default | Description |
|---|---|---|
SPIRE_MAIL_PREFIX |
admin/mail |
Route prefix for the admin interface |
SPIRE_MAIL_DISK |
public |
Storage disk for uploaded assets |
SPIRE_MAIL_VALIDATE_TAGS |
true |
Enable/disable required tag validation |
SPIRE_MAIL_LOGGING |
true |
Enable/disable logging |
SPIRE_MAIL_LOG_CHANNEL |
spire-mail |
Log channel name |
SPIRE_MAIL_LOG_LEVEL |
debug |
Log level |
Configuration File
Publish the configuration file for full customization:
php artisan spire-mail:install --publish-config
Or manually:
php artisan vendor:publish --tag=spire-mail-config
Key Configuration Options:
return [ // Route prefix for the admin interface 'route_prefix' => env('SPIRE_MAIL_PREFIX', 'admin/mail'), // Middleware applied to all routes 'middleware' => ['web', 'auth'], // Authorization settings 'authorization' => [ 'enabled' => true, 'gate' => 'manage-mail-templates', ], // Template defaults 'templates' => [ 'content_width' => 600, 'font_family' => 'Arial, sans-serif', 'background_color' => '#f5f5f5', 'content_background_color' => '#ffffff', ], // Asset storage 'storage' => [ 'disk' => env('SPIRE_MAIL_DISK', 'public'), 'path' => 'mail-assets', ], // Global merge tags 'merge_tags' => [ 'app_name' => fn () => config('app.name'), 'app_url' => fn () => config('app.url'), 'current_year' => fn () => date('Y'), ], ];
Authorization
By default, Spire Mail allows all authenticated users to manage templates. To restrict access, define a gate in your AppServiceProvider or AuthServiceProvider:
use Illuminate\Support\Facades\Gate; public function boot(): void { Gate::define('manage-mail-templates', function ($user) { return $user->hasRole('admin'); }); }
To disable authorization entirely:
// config/spire-mail.php 'authorization' => [ 'enabled' => false, ],
Sending Emails
Using SpireTemplateMailable
use SpireMail\Mail\SpireTemplateMailable; use Illuminate\Support\Facades\Mail; Mail::to('user@example.com')->send( new SpireTemplateMailable('welcome-email', [ 'user_name' => 'John Doe', 'activation_link' => 'https://example.com/activate/abc123', ]) );
Using the Trait
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use SpireMail\Mail\Concerns\UsesSpireTemplate; class WelcomeEmail extends Mailable { use UsesSpireTemplate; public function __construct(protected User $user) {} public function envelope(): Envelope { return new Envelope(subject: $this->getSpireSubject()); } public function content(): Content { return $this->useTemplate('welcome-email') ->withSpireData([ 'user_name' => $this->user->name, 'user_email' => $this->user->email, ]) ->getSpireContent(); } }
Using the Facade
use SpireMail\Facades\SpireMail; $html = SpireMail::render('newsletter-template', [ 'headline' => 'Weekly Update', 'content' => 'Here is your weekly digest...', ]);
Tag System
Spire Mail features a powerful tag system for dynamic content with formatters, conditionals, and inline fallbacks.
Basic Tags
Use double-brace syntax with dot notation for nested data:
Hello {{user.name}},
Welcome to {{app_name}}!
Your order #{{order.id}} was placed on {{order.date}}.
Inline Fallbacks
Provide default values for missing tags:
Hello {{user.nickname|default:Valued Customer}},
Your discount: {{discount_code|default:No discount applied}}
Formatters
Apply formatters using pipe syntax:
| Formatter | Syntax | Example | Output |
|---|---|---|---|
date |
{{tag|date:format}} |
{{order.date|date:d/m/Y}} |
25/12/2024 |
currency |
{{tag|currency:code}} |
{{total|currency:EUR}} |
€99.99 |
uppercase |
{{tag|uppercase}} |
{{name|uppercase}} |
JOHN DOE |
lowercase |
{{tag|lowercase}} |
{{email|lowercase}} |
john@example.com |
capitalize |
{{tag|capitalize}} |
{{name|capitalize}} |
John Doe |
truncate |
{{tag|truncate:length}} |
{{desc|truncate:50}} |
First 50 chars... |
count |
{{tag|count}} |
{{items|count}} |
5 |
number |
{{tag|number:decimals}} |
{{price|number:2}} |
99.00 |
Conditionals
Show or hide content based on data:
{{#if user.premium}}
<p>Thank you for being a premium member!</p>
{{/if}}
{{#if order.discount}}
<p>You saved {{order.discount|currency:USD}}!</p>
{{else}}
<p>No discount applied to this order.</p>
{{/if}}
{{#unless user.verified}}
<p style="color: orange;">Please verify your email address.</p>
{{/unless}}
Global Tags
Global tags are available in all templates. Define them in config/spire-mail.php:
'merge_tags' => [ 'app_name' => fn () => config('app.name'), 'app_url' => fn () => config('app.url'), 'current_year' => fn () => date('Y'), 'support_email' => 'support@example.com', ],
Registering Tags Programmatically
Register global tags in a service provider:
use SpireMail\Facades\SpireMail; public function boot(): void { SpireMail::registerTags([ 'company_name' => [ 'value' => 'Acme Inc', 'label' => 'Company Name', 'description' => 'The company name', ], 'support_email' => [ 'value' => fn () => config('mail.from.address'), 'label' => 'Support Email', 'description' => 'Support contact email', 'example' => 'support@example.com', ], ]); // Or register a single tag SpireMail::registerTag('current_date', [ 'value' => fn () => now()->format('F j, Y'), 'label' => 'Current Date', ]); }
Template-Specific Tags
Define tags per template in the editor UI (Tags tab) or programmatically:
use SpireMail\Models\MailTemplate; $template = MailTemplate::where('slug', 'order-confirmation')->first(); $template->setTags([ [ 'key' => 'user.name', 'label' => 'User Name', 'description' => 'The customer\'s full name', 'type' => 'string', 'required' => true, 'example' => 'John Doe', ], [ 'key' => 'order.total', 'label' => 'Order Total', 'description' => 'Total order amount', 'type' => 'number', 'required' => true, 'example' => '99.99', ], ]); $template->save();
Tag Validation
Spire Mail automatically validates that all required tags are provided when sending emails:
use SpireMail\Exceptions\MissingRequiredTagsException; use SpireMail\Mail\SpireTemplateMailable; try { Mail::to($user->email)->send( new SpireTemplateMailable('order-confirmation', [ 'user' => ['name' => $user->name], // Missing 'order' data - will throw if order.id is required ]) ); } catch (MissingRequiredTagsException $e) { Log::error('Missing tags', [ 'template' => $e->templateSlug, 'missing' => $e->missingTags, ]); }
Validate before queueing:
use SpireMail\Facades\SpireMail; SpireMail::validateTags('order-confirmation', $data); Mail::to($user)->queue(new SpireTemplateMailable('order-confirmation', $data));
Disable validation:
SPIRE_MAIL_VALIDATE_TAGS=false
Vue Editor Components
The npm package exports Vue components for custom integrations.
Available Exports
// Main editor components import { EmailEditor, EditorSidebar, EditorCanvas, EditorProperties, PreviewModal, } from '@sabrenski/spire-mail' // Canvas components import { CanvasBlock, CanvasDropZone } from '@sabrenski/spire-mail' // Block components import { TextBlock, ImageBlock, ButtonBlock } from '@sabrenski/spire-mail' // Property panels import { TextProperties, ImageProperties, ButtonProperties } from '@sabrenski/spire-mail' // Page components (for custom routing) import { TemplatesIndex, TemplatesEdit, TemplatesCreate } from '@sabrenski/spire-mail' // Composables and stores import { useEditorStore } from '@sabrenski/spire-mail' // Types import type { EmailBlock, EmailSettings, TemplateData, BlockDefinition, GlobalTag, TemplateTag, } from '@sabrenski/spire-mail' // Layout import { DefaultLayout } from '@sabrenski/spire-mail'
Custom Layouts
The Index and Create pages use Inertia's persistent layout pattern. After publishing the pages, you can customize the layout to use your own admin layout.
Note: The Edit page (email editor) maintains its own full-screen layout and does not support custom layouts.
Step 1: Publish the pages
php artisan vendor:publish --tag=spire-mail-pages
Step 2: Edit the layout in published files
Update the Index and Create pages to use your layout:
// resources/js/Pages/SpireMail/Templates/Index.vue <script setup lang="ts"> import AdminLayout from '@/Layouts/AdminLayout.vue' defineOptions({ layout: AdminLayout, }) // ... rest of the component </script>
// resources/js/Pages/SpireMail/Templates/Create.vue <script setup lang="ts"> import AdminLayout from '@/Layouts/AdminLayout.vue' defineOptions({ layout: AdminLayout, }) // ... rest of the component </script>
Your layout component should render the default slot:
// resources/js/Layouts/AdminLayout.vue <script setup lang="ts"> import { Sidebar, Navbar } from '@/Components' </script> <template> <div class="flex min-h-screen"> <Sidebar /> <div class="flex-1"> <Navbar /> <main> <slot /> </main> </div> </div> </template>
Importing CSS
Include the package CSS in your application:
// In your main.ts or app.ts import '@sabrenski/spire-mail/style.css'
Using EmailEditor
The main editor component for building email templates:
<script setup lang="ts"> import { EmailEditor, PreviewModal } from '@sabrenski/spire-mail' import type { TemplateData, BlockDefinition, GlobalTag } from '@sabrenski/spire-mail' interface Props { template: { data: TemplateData } | null availableBlocks: Record<string, BlockDefinition> globalTags: GlobalTag[] } const props = defineProps<Props>() function handleSave(content, settings, tags) { // Save template via Inertia or API } function handlePreview(content, settings) { // Open preview modal } </script> <template> <EmailEditor :template="template?.data" :available-blocks="availableBlocks" :global-tags="globalTags" show-back-link back-link-href="/admin/mail" @save="handleSave" @preview="handlePreview" /> </template>
Using PreviewModal
Preview and send test emails:
<script setup lang="ts"> import { ref } from 'vue' import { PreviewModal } from '@sabrenski/spire-mail' const showPreview = ref(false) const templateId = ref(1) const content = ref([]) const settings = ref({}) </script> <template> <PreviewModal v-model="showPreview" :template-id="templateId" :content="content" :settings="settings" @test-sent="handleTestSent" /> </template>
Available Blocks
| Block | Description |
|---|---|
| Text | Rich text content with formatting |
| Heading | H1, H2, H3 headings |
| Image | Responsive images with optional links |
| Button | Call-to-action buttons |
| Divider | Horizontal lines |
| Spacer | Vertical spacing |
| HTML | Raw HTML content |
| Video | Video embeds with thumbnail fallback |
| Social Icons | Social media icon links |
| Row | Multi-column layouts (1-3 columns) |
Custom Block Renderers
Create custom block types:
namespace App\Mail\Blocks; use SpireMail\Rendering\BlockRenderers\BaseBlockRenderer; class CustomBlockRenderer extends BaseBlockRenderer { public function render(array $block, array $data = []): string { $props = $block['props'] ?? []; return sprintf( '<mj-section><mj-column><mj-text>%s</mj-text></mj-column></mj-section>', $this->processMergeTags($props['content'] ?? '', $data) ); } }
Register in config:
'blocks' => [ 'custom-block' => \App\Mail\Blocks\CustomBlockRenderer::class, ],
API Endpoints
All endpoints use the configured route prefix (default: /admin/mail).
| Endpoint | Method | Description |
|---|---|---|
/ |
GET | Template list page |
/templates |
POST | Create template |
/templates/{id} |
GET | Edit template page |
/templates/{id} |
PUT | Update template |
/templates/{id} |
DELETE | Delete template |
/templates/{id}/toggle-status |
PATCH | Toggle active status |
/templates/{id}/duplicate |
POST | Duplicate template |
/templates/{id}/preview |
POST | Generate preview HTML |
/templates/{id}/send-test |
POST | Send test email |
/templates/{id}/tags |
GET | Get template tags |
/templates/{id}/tags |
PUT | Update template tags |
/tags |
GET | List global tags |
/tags/formatters |
GET | List available formatters |
/assets/upload |
POST | Upload asset |
/assets/{filename} |
DELETE | Delete asset |
Working with Templates
Query Templates
use SpireMail\Models\MailTemplate; // Get all active templates $templates = MailTemplate::active()->get(); // Find by slug $template = MailTemplate::where('slug', 'welcome-email')->first();
Create Templates Programmatically
$template = MailTemplate::create([ 'name' => 'Welcome Email', 'subject' => 'Welcome to {{app_name}}!', 'content' => ['version' => '1.0', 'blocks' => []], 'is_active' => true, ]);
Publishing Assets
php artisan vendor:publish --tag=spire-mail-config # Configuration php artisan vendor:publish --tag=spire-mail-migrations # Migrations php artisan vendor:publish --tag=spire-mail-views # Blade views php artisan vendor:publish --tag=spire-mail-lang # Language files php artisan vendor:publish --tag=spire-mail-pages # Vue pages php artisan vendor:publish --tag=spire-mail-components # Vue components
Troubleshooting
Blank page at /admin/mail
Ensure you've run the install command and built your assets:
php artisan spire-mail:install npm run build
403 Forbidden
Define the authorization gate or disable authorization:
// Option 1: Define the gate Gate::define('manage-mail-templates', fn($user) => $user->isAdmin()); // Option 2: Disable authorization // config/spire-mail.php 'authorization' => ['enabled' => false],
Missing peer dependencies
Install all required npm packages:
npm install vue @sabrenski/spire-ui-vue @hugeicons/core-free-icons @inertiajs/vue3
Asset upload fails
Ensure your storage is properly configured:
- Run
php artisan storage:link - Check that
storage/app/publicis writable - Verify
SPIRE_MAIL_DISKis correctly configured
License
MIT License. See LICENSE for more information.