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}
+ |
+
+
+
+
+
+ |
+
+
+
+
+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}},链接有效期 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}}
+ |
+
+
+
+ | 网站通知 |
+ | {!! 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}}
+ |
+
+
+
+ | 订阅即将到期 |
+ | 您的订阅服务将在 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}}
+ |
+
+
+
+ | 流量使用提醒 |
+ | 您本月的套餐流量已使用 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}}
+ |
+
+
+
+ | 邮箱验证码 |
+ | 请使用以下验证码完成验证,有效期 5 分钟。如非本人操作,请忽略此邮件。 |
+ |
+ {{$code}}
+ |
+ | 如果您没有请求此验证码,无需进行任何操作。 |
+
+ |
+
+ |
+ {{$url}}
+ 此邮件由系统自动发送,请勿直接回复。
+ |
+
+ |
+
+
+