diff --git a/app/Http/Controllers/V2/Admin/ConfigController.php b/app/Http/Controllers/V2/Admin/ConfigController.php index 81c5570..24ae512 100644 --- a/app/Http/Controllers/V2/Admin/ConfigController.php +++ b/app/Http/Controllers/V2/Admin/ConfigController.php @@ -147,7 +147,6 @@ class ConfigController extends Controller 'server_ws_url' => admin_setting('server_ws_url', ''), ], 'email' => [ - 'email_template' => admin_setting('email_template', 'default'), 'email_host' => admin_setting('email_host'), 'email_port' => admin_setting('email_port'), 'email_username' => admin_setting('email_username'), diff --git a/app/Http/Controllers/V2/Admin/MailTemplateController.php b/app/Http/Controllers/V2/Admin/MailTemplateController.php new file mode 100644 index 0000000..8561b8b --- /dev/null +++ b/app/Http/Controllers/V2/Admin/MailTemplateController.php @@ -0,0 +1,266 @@ +keyBy('name'); + + $result = []; + foreach (MailTemplate::TEMPLATES as $name => $meta) { + $db = $dbTemplates->get($name); + $result[] = [ + 'name' => $name, + 'label' => $meta['label'], + 'customized' => $db !== null, + 'subject' => $db?->subject, + 'updated_at' => $db?->updated_at?->timestamp, + ]; + } + + return $this->success($result); + } + + public function get(Request $request) + { + $name = $request->input('name'); + $meta = MailTemplate::getMeta($name); + if (!$meta) { + return $this->fail([404, '模板不存在']); + } + + $db = MailTemplate::where('name', $name)->first(); + + return $this->success([ + 'name' => $name, + 'label' => $meta['label'], + 'required_vars' => $meta['required_vars'], + 'optional_vars' => $meta['optional_vars'], + 'customized' => $db !== null, + 'subject' => $db?->subject ?? $this->getDefaultSubject($name), + 'content' => $db?->content ?? $this->getDefaultContent($name), + ]); + } + + public function save(Request $request) + { + $params = $request->validate([ + 'name' => 'required|string', + 'subject' => 'required|string|max:255', + 'content' => 'required|string', + ]); + + $meta = MailTemplate::getMeta($params['name']); + if (!$meta) { + return $this->fail([404, '模板不存在']); + } + + $errors = MailTemplate::validateContent($params['name'], $params['content']); + if (!empty($errors)) { + return $this->fail([422, implode('; ', $errors)]); + } + + MailTemplate::updateOrCreate( + ['name' => $params['name']], + ['subject' => $params['subject'], 'content' => $params['content']] + ); + Cache::forget("mail_template:{$params['name']}"); + + return $this->success(true); + } + + public function reset(Request $request) + { + $name = $request->input('name'); + $meta = MailTemplate::getMeta($name); + if (!$meta) { + return $this->fail([404, '模板不存在']); + } + + MailTemplate::where('name', $name)->delete(); + Cache::forget("mail_template:{$name}"); + return $this->success(true); + } + + public function test(Request $request) + { + $name = $request->input('name'); + $meta = MailTemplate::getMeta($name); + if (!$meta) { + return $this->fail([404, '模板不存在']); + } + + $email = $request->input('email', $request->user()->email); + $testVars = $this->getTestVars($name); + + try { + $log = MailService::sendEmail([ + 'email' => $email, + 'subject' => $this->getTestSubject($name), + 'template_name' => $name, + 'template_value' => $testVars, + ]); + + if ($log['error']) { + return $this->fail([500, '发送失败: ' . $log['error']]); + } + return $this->success(true); + } catch (\Exception $e) { + Log::error($e); + return $this->fail([500, '发送失败: ' . $e->getMessage()]); + } + } + + private function getTestSubject(string $name): string + { + $appName = admin_setting('app_name', 'XBoard'); + return match ($name) { + 'verify' => "{$appName} - 验证码测试", + 'notify' => "{$appName} - 通知测试", + 'remindExpire' => "{$appName} - 到期提醒测试", + 'remindTraffic' => "{$appName} - 流量提醒测试", + 'mailLogin' => "{$appName} - 登录链接测试", + default => "{$appName} - 邮件测试", + }; + } + + private function getTestVars(string $name): array + { + $appName = admin_setting('app_name', 'XBoard'); + $appUrl = admin_setting('app_url', 'https://example.com'); + + return match ($name) { + 'verify' => [ + 'name' => $appName, + 'code' => '123456', + 'url' => $appUrl, + ], + 'notify' => [ + 'name' => $appName, + 'content' => '这是一封测试通知邮件。', + 'url' => $appUrl, + ], + 'remindExpire' => [ + 'name' => $appName, + 'url' => $appUrl, + ], + 'remindTraffic' => [ + 'name' => $appName, + 'url' => $appUrl, + ], + 'mailLogin' => [ + 'name' => $appName, + 'link' => $appUrl . '/login?token=test-token', + 'url' => $appUrl, + ], + default => ['name' => $appName, 'url' => $appUrl], + }; + } + + private function getDefaultSubject(string $name): string + { + $appName = admin_setting('app_name', 'XBoard'); + return match ($name) { + 'verify' => "{$appName} - 邮箱验证码", + 'notify' => "{$appName} - 站点通知", + 'remindExpire' => "{$appName} - 服务即将到期", + 'remindTraffic' => "{$appName} - 流量使用提醒", + 'mailLogin' => "{$appName} - 邮件登录", + default => "{$appName}", + }; + } + + private function getDefaultContent(string $name): string + { + $theme = 'default'; + $viewName = "mail.{$theme}.{$name}"; + + try { + $viewPath = resource_path("views/mail/{$theme}/{$name}.blade.php"); + if (file_exists($viewPath)) { + $blade = file_get_contents($viewPath); + return self::bladeToPlaceholder($blade); + } + } catch (\Throwable $e) { + // ignore + } + + return self::hardcodedDefault($name); + } + + /** + * Convert Blade syntax to {{placeholder}} syntax for editing. + */ + private static function bladeToPlaceholder(string $blade): string + { + // {{$var}} → {{var}} + $result = preg_replace('/\{\{\s*\$([a-zA-Z_]+)\s*\}\}/', '{{$1}}', $blade); + // {!! nl2br($var) !!} → {{var}} + $result = preg_replace('/\{!!\s*nl2br\(\$([a-zA-Z_]+)\)\s*!!\}/', '{{$1}}', $result); + // {!! $var !!} → {{var}} + $result = preg_replace('/\{!!\s*\$([a-zA-Z_]+)\s*!!\}/', '{{$1}}', $result); + return $result; + } + + private static function hardcodedDefault(string $name): string + { + $layout = fn($title, $body) => << + + + + + + +
+
+ + + + + + + + + + + + + + +
{{name}}
{$title}
+ 尊敬的用户您好!

{$body} +
+
+
+ + + + + + +
返回{{name}}
+
+
+ +HTML; + + return match ($name) { + 'verify' => $layout('邮箱验证码', '您的验证码是:{{code}},请在 5 分钟内进行验证。如果该验证码不为您本人申请,请无视。'), + 'notify' => $layout('网站通知', '{{content}}'), + 'remindExpire' => $layout('服务到期提醒', '您的服务即将在24小时内到期,如需继续使用请及时续费。'), + 'remindTraffic' => $layout('流量使用提醒', '您的流量使用已达到80%,请注意流量使用情况。'), + 'mailLogin' => $layout('登入到{{name}}', '您正在登入到{{name}}, 请在 5 分钟内点击下方链接进行登入。如果您未授权该登入请求,请无视。{{link}}'), + default => $layout('通知', '{{content}}'), + }; + } +} diff --git a/app/Http/Requests/Admin/ConfigSave.php b/app/Http/Requests/Admin/ConfigSave.php index bec69a6..4a8f056 100755 --- a/app/Http/Requests/Admin/ConfigSave.php +++ b/app/Http/Requests/Admin/ConfigSave.php @@ -60,7 +60,6 @@ class ConfigSave extends FormRequest 'frontend_theme_color' => 'nullable|in:default,darkblue,black,green', 'frontend_background_url' => 'nullable|url', // email - 'email_template' => '', 'email_host' => '', 'email_port' => '', 'email_username' => '', diff --git a/app/Http/Routes/V2/AdminRoute.php b/app/Http/Routes/V2/AdminRoute.php index cc402ab..f94952c 100644 --- a/app/Http/Routes/V2/AdminRoute.php +++ b/app/Http/Routes/V2/AdminRoute.php @@ -2,6 +2,7 @@ namespace App\Http\Routes\V2; use App\Http\Controllers\V2\Admin\ConfigController; +use App\Http\Controllers\V2\Admin\MailTemplateController; use App\Http\Controllers\V2\Admin\PlanController; use App\Http\Controllers\V2\Admin\Server\GroupController; use App\Http\Controllers\V2\Admin\Server\RouteController; @@ -41,6 +42,17 @@ class AdminRoute $router->post('/testSendMail', [ConfigController::class, 'testSendMail']); }); + // Mail Templates + $router->group([ + 'prefix' => 'mail/template' + ], function ($router) { + $router->get('/list', [MailTemplateController::class, 'list']); + $router->get('/get', [MailTemplateController::class, 'get']); + $router->post('/save', [MailTemplateController::class, 'save']); + $router->post('/reset', [MailTemplateController::class, 'reset']); + $router->post('/test', [MailTemplateController::class, 'test']); + }); + // Plan $router->group([ 'prefix' => 'plan' diff --git a/app/Models/MailTemplate.php b/app/Models/MailTemplate.php new file mode 100644 index 0000000..c726add --- /dev/null +++ b/app/Models/MailTemplate.php @@ -0,0 +1,78 @@ + [ + 'label' => '邮箱验证码', + 'required_vars' => ['code'], + 'optional_vars' => ['name', 'url'], + ], + 'notify' => [ + 'label' => '站点通知', + 'required_vars' => ['content'], + 'optional_vars' => ['name', 'url'], + ], + 'remindExpire' => [ + 'label' => '到期提醒', + 'required_vars' => [], + 'optional_vars' => ['name', 'url'], + ], + 'remindTraffic' => [ + 'label' => '流量提醒', + 'required_vars' => [], + 'optional_vars' => ['name', 'url'], + ], + 'mailLogin' => [ + 'label' => '邮件登录', + 'required_vars' => ['link'], + 'optional_vars' => ['name', 'url'], + ], + ]; + + /** + * Get template metadata (vars, label) for a given template name. + */ + public static function getMeta(string $name): ?array + { + return self::TEMPLATES[$name] ?? null; + } + + /** + * Get all template names. + */ + public static function getNames(): array + { + return array_keys(self::TEMPLATES); + } + + /** + * Validate that required placeholders are present in the content. + */ + public static function validateContent(string $name, string $content): array + { + $meta = self::getMeta($name); + if (!$meta) { + return ["Unknown template: {$name}"]; + } + + $errors = []; + foreach ($meta['required_vars'] as $var) { + if (strpos($content, '{{' . $var . '}}') === false) { + $errors[] = "缺少必要占位符: {{{$var}}}"; + } + } + return $errors; + } +} diff --git a/app/Services/Auth/MailLinkService.php b/app/Services/Auth/MailLinkService.php index ecf7c6d..443c6d8 100644 --- a/app/Services/Auth/MailLinkService.php +++ b/app/Services/Auth/MailLinkService.php @@ -63,7 +63,7 @@ class MailLinkService 'subject' => __('Login to :name', [ 'name' => admin_setting('app_name', 'XBoard') ]), - 'template_name' => 'login', + 'template_name' => 'mailLogin', 'template_value' => [ 'name' => admin_setting('app_name', 'XBoard'), 'link' => $link, diff --git a/app/Services/MailService.php b/app/Services/MailService.php index 7630111..9bc8474 100644 --- a/app/Services/MailService.php +++ b/app/Services/MailService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Jobs\SendEmailJob; use App\Models\MailLog; +use App\Models\MailTemplate; use App\Models\User; use App\Utils\CacheKey; use Illuminate\Support\Facades\Cache; @@ -249,6 +250,7 @@ class MailService } $email = $params['email']; $subject = $params['subject']; + $templateName = $params['template_name']; $templateValue = $params['template_value'] ?? []; $vars = is_array($templateValue) ? ($templateValue['vars'] ?? []) : []; @@ -262,21 +264,44 @@ class MailService } } - // Mass mail default: treat admin content as plain text and escape. if ($contentMode === 'text' && is_array($templateValue) && isset($templateValue['content']) && is_string($templateValue['content'])) { $templateValue['content'] = e($templateValue['content']); } $params['template_value'] = $templateValue; - $params['template_name'] = 'mail.' . admin_setting('email_template', 'default') . '.' . $params['template_name']; + + // Check for DB template override (cached to avoid per-email queries in bulk sends). + // Cache 'none' sentinel for templates that don't exist in DB. + $cacheKey = "mail_template:{$templateName}"; + $cached = Cache::get($cacheKey); + if ($cached === null) { + $dbTemplate = MailTemplate::where('name', $templateName)->first(); + Cache::put($cacheKey, $dbTemplate ?: 'none', 3600); + } else { + $dbTemplate = ($cached === 'none') ? null : $cached; + } + try { - Mail::send( - $params['template_name'], - $params['template_value'], - function ($message) use ($email, $subject) { + if ($dbTemplate) { + $renderVars = self::buildSafeVars($templateValue); + $renderedSubject = self::renderPlaceholders($dbTemplate->subject, $renderVars); + $renderedContent = self::renderPlaceholders($dbTemplate->content, $renderVars); + $subject = $renderedSubject ?: $subject; + + Mail::html($renderedContent, function ($message) use ($email, $subject) { $message->to($email)->subject($subject); - } - ); + }); + $params['template_name'] = 'db:' . $templateName; + } else { + $params['template_name'] = 'mail.default.' . $templateName; + Mail::send( + $params['template_name'], + $params['template_value'], + function ($message) use ($email, $subject) { + $message->to($email)->subject($subject); + } + ); + } $error = null; } catch (\Exception $e) { Log::error($e); @@ -292,4 +317,26 @@ class MailService MailLog::create($log); return $log; } + + /** + * Build HTML-escaped vars for DB template rendering. + */ + private static function buildSafeVars(array $templateValue): array + { + $safe = []; + foreach ($templateValue as $key => $value) { + if (is_scalar($value)) { + $safe[$key] = e((string) $value); + } + } + // 'content' may be pre-escaped text or admin-authored HTML. + // For text mode, apply nl2br so line breaks survive in DB templates + // (Blade templates handle this with {!! nl2br($content) !!}). + if (isset($templateValue['content'])) { + $content = (string) $templateValue['content']; + $contentMode = $templateValue['content_mode'] ?? null; + $safe['content'] = ($contentMode === 'text') ? nl2br($content) : $content; + } + return $safe; + } } diff --git a/database/migrations/2026_04_20_000001_create_mail_templates_table.php b/database/migrations/2026_04_20_000001_create_mail_templates_table.php new file mode 100644 index 0000000..46bce03 --- /dev/null +++ b/database/migrations/2026_04_20_000001_create_mail_templates_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('name', 64)->unique(); + $table->string('subject', 255); + $table->longText('content'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('v2_mail_templates'); + } +}; diff --git a/public/assets/admin b/public/assets/admin index 37c135b..f03d07a 160000 --- a/public/assets/admin +++ b/public/assets/admin @@ -1 +1 @@ -Subproject commit 37c135bdb870259b5aa41356c78807583c46b98e +Subproject commit f03d07a87951e1492c675a37413693a371366a99 diff --git a/resources/views/mail/default/mailLogin.blade.php b/resources/views/mail/default/mailLogin.blade.php index 5047acd..8a1ecca 100644 --- a/resources/views/mail/default/mailLogin.blade.php +++ b/resources/views/mail/default/mailLogin.blade.php @@ -1,43 +1,37 @@ -
- - - - - - -
-
- - - - - - - - - - - - - - - - -
{{$name}}
登入到{{$name}}
- 尊敬的用户您好! -
-
- 您正在登入到{{$name}}, 请在 5 分钟内点击下方链接进行登入。如果您未授权该登入请求,请无视。 - {{$link}} -
-
-
- - - - - - -
返回{{$name}}
-
-
+ + + + + +邮箱登录 + + + + +
+ + + + + + + +
+ {{$name}} +
+ + + + + + +
登录确认
点击下方按钮登录到 {{$name}},链接有效期 5 分钟。如非本人操作,请忽略此邮件。
+ 确认登录 +
如果按钮无法点击,请复制以下链接到浏览器中打开:
{{$link}}
+
+ {{$url}} +

此邮件由系统自动发送,请勿直接回复。

+
+
+ + diff --git a/resources/views/mail/default/notify.blade.php b/resources/views/mail/default/notify.blade.php index 0b38ee5..c92948d 100644 --- a/resources/views/mail/default/notify.blade.php +++ b/resources/views/mail/default/notify.blade.php @@ -1,42 +1,35 @@ -
- - - - - - -
-
- - - - - - - - - - - - - - - - -
{{$name}}
网站通知
- 尊敬的用户您好! -
-
- {!! nl2br($content) !!} -
-
-
- - - - - - -
返回{{$name}}
-
-
+ + + + + +网站通知 + + + + +
+ + + + + + + +
+ {{$name}} +
+ + + + +
网站通知
{!! nl2br($content) !!}
+ 前往查看 +
+
+ {{$url}} +

此邮件由系统自动发送,请勿直接回复。

+
+
+ + diff --git a/resources/views/mail/default/remindExpire.blade.php b/resources/views/mail/default/remindExpire.blade.php index e858e28..8838321 100644 --- a/resources/views/mail/default/remindExpire.blade.php +++ b/resources/views/mail/default/remindExpire.blade.php @@ -1,42 +1,36 @@ -
- - - - - - -
-
- - - - - - - - - - - - - - - - -
{{$name}}
到期通知
- 尊敬的用户您好! -
-
- 你的服务将在24小时内到期。为了不造成使用上的影响请尽快续费。如果你已续费请忽略此邮件。 -
-
-
- - - - - - -
返回{{$name}}
-
-
+ + + + + +到期提醒 + + + + +
+ + + + + + + +
+ {{$name}} +
+ + + + + +
订阅即将到期
您的订阅服务将在 24 小时内到期。
为避免服务中断,请及时续费。如您已完成续费,请忽略此提醒。
+ 立即续费 +
+
+ {{$url}} +

此邮件由系统自动发送,请勿直接回复。

+
+
+ + diff --git a/resources/views/mail/default/remindTraffic.blade.php b/resources/views/mail/default/remindTraffic.blade.php index 022f4e9..c42a104 100644 --- a/resources/views/mail/default/remindTraffic.blade.php +++ b/resources/views/mail/default/remindTraffic.blade.php @@ -1,42 +1,36 @@ -
- - - - - - -
-
- - - - - - - - - - - - - - - - -
{{$name}}
流量通知
- 尊敬的用户您好! -
-
- 你的流量已经使用80%。为了不造成使用上的影响请合理安排流量的使用。 -
-
-
- - - - - - -
返回{{$name}}
-
-
+ + + + + +流量提醒 + + + + +
+ + + + + + + +
+ {{$name}} +
+ + + + + +
流量使用提醒
您本月的套餐流量已使用 80%
请合理安排使用,避免提前耗尽。如需更多流量,可前往面板升级套餐。
+ 查看用量 +
+
+ {{$url}} +

此邮件由系统自动发送,请勿直接回复。

+
+
+ + diff --git a/resources/views/mail/default/verify.blade.php b/resources/views/mail/default/verify.blade.php index 5c5b396..873fb09 100644 --- a/resources/views/mail/default/verify.blade.php +++ b/resources/views/mail/default/verify.blade.php @@ -1,42 +1,36 @@ -
- - - - - - -
-
- - - - - - - - - - - - - - - - -
{{$name}}
邮箱验证码
- 尊敬的用户您好! -
-
- 您的验证码是:{{$code}},请在 5 分钟内进行验证。如果该验证码不为您本人申请,请无视。 -
-
-
- - - - - - -
返回{{$name}}
-
-
+ + + + + +邮箱验证码 + + + + +
+ + + + + + + +
+ {{$name}} +
+ + + + + +
邮箱验证码
请使用以下验证码完成验证,有效期 5 分钟。如非本人操作,请忽略此邮件。
+
{{$code}}
+
如果您没有请求此验证码,无需进行任何操作。
+
+ {{$url}} +

此邮件由系统自动发送,请勿直接回复。

+
+
+ +