From fcd1f0f034f9352d188a3df26b7ed19a7c368b97 Mon Sep 17 00:00:00 2001 From: Blaize Kaye Date: Tue, 18 Nov 2025 07:36:25 +1300 Subject: [PATCH 1/3] Adds ability to pass template as constructor --- app/Mail/AppInstanceMidtrialMail.php | 15 ++++++--------- app/Mail/AppInstanceOneDayLeftMail.php | 15 ++++++--------- app/Mail/AppInstanceReadyMail.php | 5 +++-- app/Mail/AppInstanceTrialCompleteMail.php | 15 ++++++--------- 4 files changed, 21 insertions(+), 29 deletions(-) diff --git a/app/Mail/AppInstanceMidtrialMail.php b/app/Mail/AppInstanceMidtrialMail.php index aae8f65..b9a3b6d 100644 --- a/app/Mail/AppInstanceMidtrialMail.php +++ b/app/Mail/AppInstanceMidtrialMail.php @@ -12,21 +12,18 @@ class AppInstanceMidtrialMail extends Mailable { use Queueable, SerializesModels; - public PolydockAppInstance $appInstance; - public User $toUser; - - public function __construct(PolydockAppInstance $appInstance, User $toUser) - { - $this->appInstance = $appInstance; - $this->toUser = $toUser; - } + public function __construct( + public PolydockAppInstance $appInstance, + public User $toUser, + public string $markdownTemplate = 'emails.app-instance.midtrial' + ) {} public function build() { $subject = $this->appInstance->storeApp->midtrial_email_subject ?? 'Halfway Through Your Trial'; $subject .= " [" . $this->appInstance->name . "]"; - return $this->markdown('emails.app-instance.midtrial') + return $this->markdown($this->markdownTemplate) ->subject($subject); } } \ No newline at end of file diff --git a/app/Mail/AppInstanceOneDayLeftMail.php b/app/Mail/AppInstanceOneDayLeftMail.php index 76b0e58..6e87a2d 100644 --- a/app/Mail/AppInstanceOneDayLeftMail.php +++ b/app/Mail/AppInstanceOneDayLeftMail.php @@ -12,21 +12,18 @@ class AppInstanceOneDayLeftMail extends Mailable { use Queueable, SerializesModels; - public PolydockAppInstance $appInstance; - public User $toUser; - - public function __construct(PolydockAppInstance $appInstance, User $toUser) - { - $this->appInstance = $appInstance; - $this->toUser = $toUser; - } + public function __construct( + public PolydockAppInstance $appInstance, + public User $toUser, + public string $markdownTemplate = 'emails.app-instance.one-day-left' + ) {} public function build() { $subject = $this->appInstance->storeApp->one_day_left_email_subject ?? 'One Day Left in Your Trial'; $subject .= " [" . $this->appInstance->name . "]"; - return $this->markdown('emails.app-instance.one-day-left') + return $this->markdown($this->markdownTemplate) ->subject($subject); } } \ No newline at end of file diff --git a/app/Mail/AppInstanceReadyMail.php b/app/Mail/AppInstanceReadyMail.php index 304e207..223789f 100644 --- a/app/Mail/AppInstanceReadyMail.php +++ b/app/Mail/AppInstanceReadyMail.php @@ -19,7 +19,8 @@ class AppInstanceReadyMail extends Mailable */ public function __construct( public PolydockAppInstance $appInstance, - public User $toUser + public User $toUser, + public string $markdownTemplate = 'emails.app-instance.ready' ) {} /** @@ -46,7 +47,7 @@ public function envelope(): Envelope public function content(): Content { return new Content( - markdown: 'emails.app-instance.ready', + markdown: $this->markdownTemplate, ); } diff --git a/app/Mail/AppInstanceTrialCompleteMail.php b/app/Mail/AppInstanceTrialCompleteMail.php index 07fd74e..06ffcc8 100644 --- a/app/Mail/AppInstanceTrialCompleteMail.php +++ b/app/Mail/AppInstanceTrialCompleteMail.php @@ -12,21 +12,18 @@ class AppInstanceTrialCompleteMail extends Mailable { use Queueable, SerializesModels; - public PolydockAppInstance $appInstance; - public User $toUser; - - public function __construct(PolydockAppInstance $appInstance, User $toUser) - { - $this->appInstance = $appInstance; - $this->toUser = $toUser; - } + public function __construct( + public PolydockAppInstance $appInstance, + public User $toUser, + public string $markdownTemplate = 'emails.app-instance.trial-complete' + ) {} public function build() { $subject = $this->appInstance->storeApp->trial_complete_email_subject ?? 'Your Trial Has Ended'; $subject .= " [" . $this->appInstance->name . "]"; - return $this->markdown('emails.app-instance.trial-complete') + return $this->markdown($this->markdownTemplate) ->subject($subject); } } \ No newline at end of file From 928dd4dcab303f4c9bea00fe12b61a7bc12ed0e9 Mon Sep 17 00:00:00 2001 From: Blaize Kaye Date: Tue, 18 Nov 2025 09:15:13 +1300 Subject: [PATCH 2/3] Adds ability to reference custom templates --- app/Providers/CustomTemplateProvider.php | 37 ++++++++++++++++++++++++ bootstrap/providers.php | 1 + 2 files changed, 38 insertions(+) create mode 100644 app/Providers/CustomTemplateProvider.php diff --git a/app/Providers/CustomTemplateProvider.php b/app/Providers/CustomTemplateProvider.php new file mode 100644 index 0000000..da457d7 --- /dev/null +++ b/app/Providers/CustomTemplateProvider.php @@ -0,0 +1,37 @@ +loadViewsFrom($templatePath, 'custom'); + + Log::info('Custom email templates loaded', [ + 'path' => $templatePath, + 'templates_found' => count(glob($templatePath . '/*.blade.php')) + ]); + } else { + Log::debug('Custom templates directory not found', ['path' => $templatePath]); + } + } + + /** + * Bootstrap services. + */ + public function boot(): void + { + // + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 9977dbd..60ff037 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,6 +2,7 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\CustomTemplateProvider::class, App\Providers\Filament\AdminPanelProvider::class, App\Providers\Filament\AppPanelProvider::class, App\Providers\HorizonServiceProvider::class, From ade0b46eaf23686323d30c0520549bae3050958b Mon Sep 17 00:00:00 2001 From: Blaize Kaye Date: Fri, 21 Nov 2025 14:16:24 +1300 Subject: [PATCH 3/3] Fully fleshes out Theming layer --- .../Resources/PolydockStoreAppResource.php | 5 ++ app/Mail/AppInstanceMidtrialMail.php | 5 +- app/Mail/AppInstanceOneDayLeftMail.php | 8 ++- app/Mail/AppInstanceReadyMail.php | 8 ++- app/Mail/AppInstanceTrialCompleteMail.php | 5 +- app/Mail/Traits/ResolvesThemeTemplate.php | 35 ++++++++++ app/Models/PolydockStoreApp.php | 1 + app/Providers/CustomTemplateProvider.php | 41 +++++++++++- ...17_232329_add_markdown_template_to_app.php | 28 ++++++++ docs/MAIL_THEMING.md | 67 +++++++++++++++++++ 10 files changed, 194 insertions(+), 9 deletions(-) create mode 100644 app/Mail/Traits/ResolvesThemeTemplate.php create mode 100644 database/migrations/2025_11_17_232329_add_markdown_template_to_app.php create mode 100644 docs/MAIL_THEMING.md diff --git a/app/Filament/Admin/Resources/PolydockStoreAppResource.php b/app/Filament/Admin/Resources/PolydockStoreAppResource.php index e821b98..9087e50 100644 --- a/app/Filament/Admin/Resources/PolydockStoreAppResource.php +++ b/app/Filament/Admin/Resources/PolydockStoreAppResource.php @@ -52,6 +52,11 @@ public static function form(Form $form): Form Forms\Components\TextInput::make('name') ->required() ->maxLength(255), + Forms\Components\Select::make('mail_theme') + ->label('Email Theme') + ->options(fn() => array_merge(['null' => 'Default'], array_combine(app('mail.themes'), app('mail.themes')))) + ->nullable() + ->dehydrateStateUsing(fn(?string $state) => $state === 'null' ? null : $state), Forms\Components\Textarea::make('description') ->required() ->columnSpanFull(), diff --git a/app/Mail/AppInstanceMidtrialMail.php b/app/Mail/AppInstanceMidtrialMail.php index b9a3b6d..a4ff7b1 100644 --- a/app/Mail/AppInstanceMidtrialMail.php +++ b/app/Mail/AppInstanceMidtrialMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Traits\ResolvesThemeTemplate; use App\Models\PolydockAppInstance; use App\Models\User; use Illuminate\Bus\Queueable; @@ -10,7 +11,7 @@ class AppInstanceMidtrialMail extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, ResolvesThemeTemplate; public function __construct( public PolydockAppInstance $appInstance, @@ -20,6 +21,8 @@ public function __construct( public function build() { + $this->resolveThemeTemplate($this->appInstance->storeApp->mail_theme, $this->markdownTemplate); + $subject = $this->appInstance->storeApp->midtrial_email_subject ?? 'Halfway Through Your Trial'; $subject .= " [" . $this->appInstance->name . "]"; diff --git a/app/Mail/AppInstanceOneDayLeftMail.php b/app/Mail/AppInstanceOneDayLeftMail.php index 6e87a2d..29d39be 100644 --- a/app/Mail/AppInstanceOneDayLeftMail.php +++ b/app/Mail/AppInstanceOneDayLeftMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Traits\ResolvesThemeTemplate; use App\Models\PolydockAppInstance; use App\Models\User; use Illuminate\Bus\Queueable; @@ -10,16 +11,19 @@ class AppInstanceOneDayLeftMail extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, ResolvesThemeTemplate; public function __construct( public PolydockAppInstance $appInstance, public User $toUser, public string $markdownTemplate = 'emails.app-instance.one-day-left' - ) {} + ) { + } public function build() { + $this->resolveThemeTemplate($this->appInstance->storeApp->mail_theme, $this->markdownTemplate); + $subject = $this->appInstance->storeApp->one_day_left_email_subject ?? 'One Day Left in Your Trial'; $subject .= " [" . $this->appInstance->name . "]"; diff --git a/app/Mail/AppInstanceReadyMail.php b/app/Mail/AppInstanceReadyMail.php index 223789f..ce9ed48 100644 --- a/app/Mail/AppInstanceReadyMail.php +++ b/app/Mail/AppInstanceReadyMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Traits\ResolvesThemeTemplate; use App\Models\PolydockAppInstance; use App\Models\User; use Illuminate\Bus\Queueable; @@ -12,7 +13,7 @@ class AppInstanceReadyMail extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, ResolvesThemeTemplate; /** * Create a new message instance. @@ -21,13 +22,16 @@ public function __construct( public PolydockAppInstance $appInstance, public User $toUser, public string $markdownTemplate = 'emails.app-instance.ready' - ) {} + ) { + } /** * Get the message envelope. */ public function envelope(): Envelope { + $this->resolveThemeTemplate($this->appInstance->storeApp->mail_theme, $this->markdownTemplate); + $subject = $this->appInstance->storeApp->email_subject_line; if (empty($subject)) { diff --git a/app/Mail/AppInstanceTrialCompleteMail.php b/app/Mail/AppInstanceTrialCompleteMail.php index 06ffcc8..62994ec 100644 --- a/app/Mail/AppInstanceTrialCompleteMail.php +++ b/app/Mail/AppInstanceTrialCompleteMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Traits\ResolvesThemeTemplate; use App\Models\PolydockAppInstance; use App\Models\User; use Illuminate\Bus\Queueable; @@ -10,7 +11,7 @@ class AppInstanceTrialCompleteMail extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, ResolvesThemeTemplate; public function __construct( public PolydockAppInstance $appInstance, @@ -20,6 +21,8 @@ public function __construct( public function build() { + $this->resolveThemeTemplate($this->appInstance->storeApp->mail_theme, $this->markdownTemplate); + $subject = $this->appInstance->storeApp->trial_complete_email_subject ?? 'Your Trial Has Ended'; $subject .= " [" . $this->appInstance->name . "]"; diff --git a/app/Mail/Traits/ResolvesThemeTemplate.php b/app/Mail/Traits/ResolvesThemeTemplate.php new file mode 100644 index 0000000..4164882 --- /dev/null +++ b/app/Mail/Traits/ResolvesThemeTemplate.php @@ -0,0 +1,35 @@ +theme and $this->markdownTemplate accordingly. + * + * @param string $themeBase The theme namespace (e.g., 'promet') + * @param string $markdownTemplate The default markdown template path + * @throws \Exception + */ + protected function resolveThemeTemplate(string|null $themeBase, string $markdownTemplate): void + { + if (empty($themeBase)) { + return; + } + + $this->theme = sprintf("%s::%s", $themeBase, "emails.theme"); + + $themedTemplate = sprintf("%s::%s", $themeBase, $markdownTemplate); + + if (View::exists($themedTemplate)) { + $this->markdownTemplate = $themedTemplate; + } else { + if (!View::exists($markdownTemplate)) { + throw new \Exception("Unable to find any template corresponding to " . $markdownTemplate); + } + } + } +} diff --git a/app/Models/PolydockStoreApp.php b/app/Models/PolydockStoreApp.php index 8579ef4..84ecc62 100644 --- a/app/Models/PolydockStoreApp.php +++ b/app/Models/PolydockStoreApp.php @@ -34,6 +34,7 @@ class PolydockStoreApp extends Model 'lagoon_remove_script', 'email_subject_line', 'email_body_markdown', + 'mail_theme', 'status', 'uuid', 'available_for_trials', diff --git a/app/Providers/CustomTemplateProvider.php b/app/Providers/CustomTemplateProvider.php index da457d7..53882f7 100644 --- a/app/Providers/CustomTemplateProvider.php +++ b/app/Providers/CustomTemplateProvider.php @@ -13,18 +13,53 @@ class CustomTemplateProvider extends ServiceProvider public function register(): void { $templatePath = config('mail.custom_templates_path', storage_path('app/private/templates')); + $themes = []; // Only register custom template path if directory exists if (is_dir($templatePath)) { - $this->loadViewsFrom($templatePath, 'custom'); + $themes = $this->discoverThemes($templatePath); - Log::info('Custom email templates loaded', [ + foreach ($themes as $themeName => $themePath) { + $this->loadViewsFrom($themePath, $themeName); + Log::info('Custom theme registered', [ + 'theme' => $themeName, + 'path' => $themePath, + ]); + } + + Log::info('Custom email themes loaded', [ 'path' => $templatePath, - 'templates_found' => count(glob($templatePath . '/*.blade.php')) + 'themes_found' => count($themes), + 'theme_names' => array_keys($themes) ]); } else { Log::debug('Custom templates directory not found', ['path' => $templatePath]); } + + // Make themes available globally via service container + $this->app->singleton('mail.themes', fn() => array_keys($themes)); + } + + /** + * Discover theme directories and return them as an array. + * + * @param string $templatePath + * @return array Array of theme name => path + */ + private function discoverThemes(string $templatePath): array + { + $themes = []; + + $directories = array_filter( + scandir($templatePath), + fn($item) => is_dir($templatePath . DIRECTORY_SEPARATOR . $item) && !str_starts_with($item, '.') + ); + + foreach ($directories as $dir) { + $themes[$dir] = $templatePath . DIRECTORY_SEPARATOR . $dir; + } + + return $themes; } /** diff --git a/database/migrations/2025_11_17_232329_add_markdown_template_to_app.php b/database/migrations/2025_11_17_232329_add_markdown_template_to_app.php new file mode 100644 index 0000000..0a0c0b3 --- /dev/null +++ b/database/migrations/2025_11_17_232329_add_markdown_template_to_app.php @@ -0,0 +1,28 @@ +string('mail_theme')->nullable()->after('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('polydock_store_apps', function (Blueprint $table) { + $table->dropColumn('mail_theme'); + }); + } +}; diff --git a/docs/MAIL_THEMING.md b/docs/MAIL_THEMING.md new file mode 100644 index 0000000..59d9169 --- /dev/null +++ b/docs/MAIL_THEMING.md @@ -0,0 +1,67 @@ +# Mail Theming System + +## Overview + +The Mail Theming System allows you to create and manage multiple email templates and themes for transactional emails sent by the Polydock Engine. Each theme can override the default email layout, styling, and content while maintaining fallback to default templates if themed versions don't exist. + +## Directory Structure + +``` +storage/app/private/templates/ +├── theme-name/ # Theme name (directory name = theme key) +│ ├── emails/ +│ │ └── app-instance/ +│ │ ├── ready.blade.php (optional) +│ │ ├── midtrial.blade.php (optional) +│ │ ├── one-day-left.blade.php (optional) +│ │ └── trial-complete.blade.php (optional) +│ └── css/ +│ └── theme.css # Optional theme-specific styles +├── another-theme/ +│ └── emails/ +│ └── ... +``` + +## Creating a New Theme + +### Step 1: Create Theme Directory + +```bash +mkdir -p storage/app/private/templates/your-theme-name/emails/app-instance +``` + +### Step 2: create your theme + +Add a theme.css file with your mail's css + +### Step 3: Create Email Templates (Optional) + +If you want to override specific email templates, create them in the theme directory: + +- `storage/app/private/templates/your-theme-name/emails/app-instance/ready.blade.php` +- `storage/app/private/templates/your-theme-name/emails/app-instance/midtrial.blade.php` +- `storage/app/private/templates/your-theme-name/emails/app-instance/one-day-left.blade.php` +- `storage/app/private/templates/your-theme-name/emails/app-instance/trial-complete.blade.php` + +If these files don't exist, the system falls back to the default templates in `resources/views/emails/app-instance/`. + +## Using Themes + +### In the Admin UI + +1. Navigate to Apps → Apps +2. Create or edit a store app +3. Select a theme from the **Email Theme** dropdown +4. Save the record + +The theme name will be stored in the `polydock_store_apps.mail_theme` column. + +## Theme Resolution Logic + +When an email is sent: + +1. Check if a `mail_theme` is set on the store app +2. If set, resolve the themed template path (e.g., `promet::emails.app-instance.ready`) +3. If the themed template exists, use it +4. If the themed template doesn't exist, fall back to the default template (e.g., `emails.app-instance.ready`) +5. If no fallback exists, throw an exception