From 17a7c63aecc3bd2181b27c9f23cc34b5756694f0 Mon Sep 17 00:00:00 2001 From: yinjianm Date: Sun, 22 Feb 2026 03:22:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=82=AE=E4=BB=B6=E9=83=A8?= =?UTF-8?q?=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 +- .../V1/Passport/CommController.php | 4 +- .../Controllers/V2/Admin/ConfigController.php | 6 +- .../Controllers/V2/Admin/UserController.php | 11 +- app/Jobs/SendEmailJob.php | 16 +- app/Services/Auth/MailLinkService.php | 6 +- app/Services/MailService.php | 227 +++++++++++++----- app/Services/TicketService.php | 4 +- 8 files changed, 206 insertions(+), 71 deletions(-) diff --git a/.env.example b/.env.example index 1125ee1..ffd7889 100755 --- a/.env.example +++ b/.env.example @@ -20,6 +20,7 @@ REDIS_PORT=6379 BROADCAST_DRIVER=log CACHE_DRIVER=redis QUEUE_CONNECTION=redis +MASS_EMAIL_HOURLY_LIMIT=500 MAIL_DRIVER=smtp MAIL_HOST=smtp.mailtrap.io @@ -38,4 +39,4 @@ GOOGLE_CLOUD_KEY_FILE=config/googleCloudStorageKey.json GOOGLE_CLOUD_STORAGE_BUCKET= # Prevent reinstallation -INSTALLED=false \ No newline at end of file +INSTALLED=false diff --git a/app/Http/Controllers/V1/Passport/CommController.php b/app/Http/Controllers/V1/Passport/CommController.php index 65365d8..f89f884 100644 --- a/app/Http/Controllers/V1/Passport/CommController.php +++ b/app/Http/Controllers/V1/Passport/CommController.php @@ -44,14 +44,14 @@ class CommController extends Controller return $this->fail([400, __('Email verification code has been sent, please request again later')]); } $code = rand(100000, 999999); - $subject = admin_setting('app_name', 'XBoard') . __('Email verification code'); + $subject = admin_setting('app_name', 'Notification Service') . __('Email verification code'); SendEmailJob::dispatch([ 'email' => $email, 'subject' => $subject, 'template_name' => 'verify', 'template_value' => [ - 'name' => admin_setting('app_name', 'XBoard'), + 'name' => admin_setting('app_name', 'Notification Service'), 'code' => $code, 'url' => admin_setting('app_url') ] diff --git a/app/Http/Controllers/V2/Admin/ConfigController.php b/app/Http/Controllers/V2/Admin/ConfigController.php index b3a7ce8..5bc7755 100644 --- a/app/Http/Controllers/V2/Admin/ConfigController.php +++ b/app/Http/Controllers/V2/Admin/ConfigController.php @@ -45,11 +45,11 @@ class ConfigController extends Controller { $mailLog = MailService::sendEmail([ 'email' => $request->user()->email, - 'subject' => 'This is xboard test email', + 'subject' => 'Mail connectivity test', 'template_name' => 'notify', 'template_value' => [ - 'name' => admin_setting('app_name', 'XBoard'), - 'content' => 'This is xboard test email', + 'name' => admin_setting('app_name', 'Notification Service'), + 'content' => 'This is a mail connectivity test', 'url' => admin_setting('app_url') ] ]); diff --git a/app/Http/Controllers/V2/Admin/UserController.php b/app/Http/Controllers/V2/Admin/UserController.php index 3e5e341..b6dddbb 100644 --- a/app/Http/Controllers/V2/Admin/UserController.php +++ b/app/Http/Controllers/V2/Admin/UserController.php @@ -441,27 +441,32 @@ class UserController extends Controller ini_set('memory_limit', '-1'); $sortType = in_array($request->input('sort_type'), ['ASC', 'DESC']) ? $request->input('sort_type') : 'DESC'; $sort = $request->input('sort') ? $request->input('sort') : 'created_at'; + $hourlyLimit = (int) env('MASS_EMAIL_HOURLY_LIMIT', 500); + $hourlyLimit = $hourlyLimit > 0 ? $hourlyLimit : 500; $builder = User::orderBy($sort, $sortType); $this->applyFiltersAndSorts($request, $builder); $subject = $request->input('subject'); $content = $request->input('content'); $templateValue = [ - 'name' => admin_setting('app_name', 'XBoard'), + 'name' => admin_setting('app_name', 'Notification Service'), 'url' => admin_setting('app_url'), 'content' => $content ]; $chunkSize = 1000; + $processed = 0; - $builder->chunk($chunkSize, function ($users) use ($subject, $templateValue, &$totalProcessed) { + $builder->chunk($chunkSize, function ($users) use ($subject, $templateValue, $hourlyLimit, &$processed) { foreach ($users as $user) { + $delaySeconds = intdiv($processed, $hourlyLimit) * 3600; dispatch(new SendEmailJob([ 'email' => $user->email, 'subject' => $subject, 'template_name' => 'notify', 'template_value' => $templateValue - ], 'send_email_mass')); + ], 'send_email_mass'))->delay(now()->addSeconds($delaySeconds)); + $processed++; } }); diff --git a/app/Jobs/SendEmailJob.php b/app/Jobs/SendEmailJob.php index d998f41..c5df45f 100644 --- a/app/Jobs/SendEmailJob.php +++ b/app/Jobs/SendEmailJob.php @@ -16,6 +16,7 @@ class SendEmailJob implements ShouldQueue public $tries = 3; public $timeout = 10; + /** * Create a new job instance. * @@ -34,9 +35,20 @@ class SendEmailJob implements ShouldQueue */ public function handle() { - $mailLog = MailService::sendEmail($this->params); + $params = $this->params; + if ( + $this->queue === 'send_email_mass' + && isset($params['template_value']) + && is_array($params['template_value']) + && array_key_exists('content', $params['template_value']) + ) { + $timestamp = now()->format('Y-m-d H:i:s.u'); + $params['template_value']['content'] = (string) $params['template_value']['content'] . "\r\n\r\n[Send-Time: {$timestamp}]"; + } + + $mailLog = MailService::sendEmail($params); if ($mailLog['error']) { - $this->release(); //发送失败将触发重试 + $this->release(60); } } } diff --git a/app/Services/Auth/MailLinkService.php b/app/Services/Auth/MailLinkService.php index e2351e0..1248498 100644 --- a/app/Services/Auth/MailLinkService.php +++ b/app/Services/Auth/MailLinkService.php @@ -61,11 +61,11 @@ class MailLinkService SendEmailJob::dispatch([ 'email' => $user->email, 'subject' => __('Login to :name', [ - 'name' => admin_setting('app_name', 'XBoard') + 'name' => admin_setting('app_name', 'Notification Service') ]), 'template_name' => 'login', 'template_value' => [ - 'name' => admin_setting('app_name', 'XBoard'), + 'name' => admin_setting('app_name', 'Notification Service'), 'link' => $link, 'url' => admin_setting('app_url') ] @@ -97,4 +97,4 @@ class MailLinkService return $userId; } -} \ No newline at end of file +} diff --git a/app/Services/MailService.php b/app/Services/MailService.php index a7a9f55..ab964b7 100644 --- a/app/Services/MailService.php +++ b/app/Services/MailService.php @@ -54,7 +54,6 @@ class MailService $progressCallback(); } - // 定期清理内存 if ($statistics['processed_users'] % 2500 === 0) { gc_collect_cycles(); } @@ -73,14 +72,12 @@ class MailService $statistics['processed_users']++; $emailsSent = 0; - // 检查并发送过期提醒 if ($user->remind_expire && $this->shouldSendExpireRemind($user)) { $this->remindExpire($user); $statistics['expire_emails']++; $emailsSent++; } - // 检查并发送流量提醒 if ($user->remind_traffic && $this->shouldSendTrafficRemind($user)) { $this->remindTraffic($user); $statistics['traffic_emails']++; @@ -90,14 +87,13 @@ class MailService if ($emailsSent === 0) { $statistics['skipped']++; } - } catch (\Exception $e) { $statistics['errors']++; Log::error('发送提醒邮件失败', [ 'user_id' => $user->id, 'email' => $user->email, - 'error' => $e->getMessage() + 'error' => $e->getMessage(), ]); } } @@ -108,15 +104,13 @@ class MailService */ private function shouldSendExpireRemind(User $user): bool { - if ($user->expired_at === NULL) { + if ($user->expired_at === null) { return false; } + $expiredAt = $user->expired_at; $now = time(); - if (($expiredAt - 86400) < $now && $expiredAt > $now) { - return true; - } - return false; + return ($expiredAt - 86400) < $now && $expiredAt > $now; } /** @@ -131,32 +125,36 @@ class MailService $usedBytes = $user->u + $user->d; $usageRatio = $usedBytes / $user->transfer_enable; - // 流量使用超过80%时发送提醒 return $usageRatio >= 0.8; } public function remindTraffic(User $user) { - if (!$user->remind_traffic) + if (!$user->remind_traffic) { return; - if (!$this->remindTrafficIsWarnValue($user->u, $user->d, $user->transfer_enable)) + } + if (!$this->remindTrafficIsWarnValue($user->u, $user->d, $user->transfer_enable)) { return; + } + $flag = CacheKey::get('LAST_SEND_EMAIL_REMIND_TRAFFIC', $user->id); - if (Cache::get($flag)) + if (Cache::get($flag)) { return; - if (!Cache::put($flag, 1, 24 * 3600)) + } + if (!Cache::put($flag, 1, 24 * 3600)) { return; + } SendEmailJob::dispatch([ 'email' => $user->email, 'subject' => __('The traffic usage in :app_name has reached 80%', [ - 'app_name' => admin_setting('app_name', 'XBoard') + 'app_name' => admin_setting('app_name', 'Notification Service'), ]), 'template_name' => 'remindTraffic', 'template_value' => [ - 'name' => admin_setting('app_name', 'XBoard'), - 'url' => admin_setting('app_url') - ] + 'name' => admin_setting('app_name', 'Notification Service'), + 'url' => admin_setting('app_url'), + ], ]); } @@ -169,48 +167,47 @@ class MailService SendEmailJob::dispatch([ 'email' => $user->email, 'subject' => __('The service in :app_name is about to expire', [ - 'app_name' => admin_setting('app_name', 'XBoard') + 'app_name' => admin_setting('app_name', 'Notification Service'), ]), 'template_name' => 'remindExpire', 'template_value' => [ - 'name' => admin_setting('app_name', 'XBoard'), - 'url' => admin_setting('app_url') - ] + 'name' => admin_setting('app_name', 'Notification Service'), + 'url' => admin_setting('app_url'), + ], ]); } private function remindTrafficIsWarnValue($u, $d, $transfer_enable) { $ud = $u + $d; - if (!$ud) + if (!$ud) { return false; - if (!$transfer_enable) + } + if (!$transfer_enable) { return false; + } + $percentage = ($ud / $transfer_enable) * 100; - if ($percentage < 80) + if ($percentage < 80) { return false; - if ($percentage >= 100) + } + if ($percentage >= 100) { return false; + } + return true; } /** * 发送邮件 - * - * @param array $params 包含邮件参数的数组,必须包含以下字段: - * - email: 收件人邮箱地址 - * - subject: 邮件主题 - * - template_name: 邮件模板名称,例如 "welcome" 或 "password_reset" - * - template_value: 邮件模板变量,一个关联数组,包含模板中需要替换的变量和对应的值 - * @return array 包含邮件发送结果的数组,包含以下字段: - * - email: 收件人邮箱地址 - * - subject: 邮件主题 - * - template_name: 邮件模板名称 - * - error: 如果邮件发送失败,包含错误信息;否则为 null - * @throws \InvalidArgumentException 如果 $params 参数缺少必要的字段,抛出此异常 */ public static function sendEmail(array $params) { + $appName = self::sanitizeMailText((string) admin_setting('app_name', config('app.name', 'Notification Service'))); + if ($appName === '') { + $appName = 'Notification Service'; + } + if (admin_setting('email_host')) { Config::set('mail.host', admin_setting('email_host', config('mail.host'))); Config::set('mail.port', admin_setting('email_port', config('mail.port'))); @@ -218,32 +215,152 @@ class MailService Config::set('mail.username', admin_setting('email_username', config('mail.username'))); Config::set('mail.password', admin_setting('email_password', config('mail.password'))); Config::set('mail.from.address', admin_setting('email_from_address', config('mail.from.address'))); - Config::set('mail.from.name', admin_setting('app_name', 'XBoard')); } - $email = $params['email']; - $subject = $params['subject']; - $params['template_name'] = 'mail.' . admin_setting('email_template', 'default') . '.' . $params['template_name']; + Config::set('mail.from.name', $appName); + + $params['template_value'] = isset($params['template_value']) && is_array($params['template_value']) + ? $params['template_value'] + : []; + + if (array_key_exists('name', $params['template_value'])) { + $params['template_value']['name'] = self::sanitizeMailText((string) $params['template_value']['name']); + } else { + $params['template_value']['name'] = $appName; + } + + if ($params['template_value']['name'] === '') { + $params['template_value']['name'] = $appName; + } + + if (array_key_exists('content', $params['template_value'])) { + $params['template_value']['content'] = self::sanitizeMailText((string) $params['template_value']['content']); + } + + $email = (string) $params['email']; + $subject = self::sanitizeMailText((string) $params['subject']); + if ($subject === '') { + $subject = 'Notification'; + } + + $originTemplateName = (string) $params['template_name']; + $params['template_name'] = 'mail.' . admin_setting('email_template', 'default') . '.' . $originTemplateName; + $logTemplateName = $params['template_name']; + try { - Mail::send( - $params['template_name'], - $params['template_value'], - function ($message) use ($email, $subject) { - $message->to($email)->subject($subject); - } - ); + if ($originTemplateName === 'notify') { + $html = self::buildModernNotifyHtml($params['template_value'], $subject, $appName); + self::sendHtmlMail($email, $subject, $html); + $logTemplateName = 'mail.modern.notify'; + } else { + Mail::send( + $params['template_name'], + $params['template_value'], + function ($message) use ($email, $subject) { + $message->to($email)->subject($subject); + } + ); + } $error = null; - } catch (\Exception $e) { + } catch (\Throwable $e) { Log::error($e); $error = $e->getMessage(); } + $log = [ - 'email' => $params['email'], - 'subject' => $params['subject'], - 'template_name' => $params['template_name'], + 'email' => $email, + 'subject' => $subject, + 'template_name' => $logTemplateName, 'error' => $error, - 'config' => config('mail') + 'config' => config('mail'), ]; MailLog::create($log); + return $log; } + + private static function sanitizeMailText(string $text): string + { + $cleaned = str_ireplace(['xboard', 'v2board'], '', $text); + $cleaned = preg_replace('/\s+/', ' ', $cleaned); + + return trim((string) $cleaned); + } + + private static function escapeHtml(string $text): string + { + return htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); + } + + private static function buildModernNotifyHtml(array $templateValue, string $subject, string $appName): string + { + $title = self::escapeHtml($subject); + $brand = self::escapeHtml((string) ($templateValue['name'] ?? $appName)); + $content = self::escapeHtml((string) ($templateValue['content'] ?? '')); + $content = nl2br($content, false); + $sendTime = self::escapeHtml(now()->format('Y-m-d H:i:s')); + + $cta = ''; + $url = isset($templateValue['url']) ? (string) $templateValue['url'] : ''; + if (filter_var($url, FILTER_VALIDATE_URL)) { + $safeUrl = self::escapeHtml($url); + $cta = 'Open Link'; + } + + return ' + + + + + ' . $title . ' + + +
+
+
+
Message
+

' . $title . '

+
+
+
' . $content . '
+
' . $cta . '
+
+
+
Sender: ' . $brand . '
+
Sent at: ' . $sendTime . '
+
+
+
+ +'; + } + + private static function sendHtmlMail(string $email, string $subject, string $html): void + { + $mailer = Mail::getFacadeRoot(); + if ($mailer && method_exists($mailer, 'html')) { + Mail::html($html, function ($message) use ($email, $subject) { + $message->to($email)->subject($subject); + }); + return; + } + + Mail::send([], [], function ($message) use ($email, $subject, $html) { + $message->to($email)->subject($subject); + + if (method_exists($message, 'getSymfonyMessage')) { + $symfonyMessage = $message->getSymfonyMessage(); + if ($symfonyMessage && method_exists($symfonyMessage, 'html')) { + $symfonyMessage->html($html); + return; + } + } + + if (method_exists($message, 'setBody')) { + $message->setBody($html, 'text/html'); + return; + } + + throw new \RuntimeException('Unsupported mail message driver for html body.'); + }); + } } diff --git a/app/Services/TicketService.php b/app/Services/TicketService.php index f99d7c7..8ff6ff7 100644 --- a/app/Services/TicketService.php +++ b/app/Services/TicketService.php @@ -112,10 +112,10 @@ class TicketService Cache::put($cacheKey, 1, 1800); SendEmailJob::dispatch([ 'email' => $user->email, - 'subject' => '您在' . admin_setting('app_name', 'XBoard') . '的工单得到了回复', + 'subject' => '您在' . admin_setting('app_name', 'Notification Service') . '的工单得到了回复', 'template_name' => 'notify', 'template_value' => [ - 'name' => admin_setting('app_name', 'XBoard'), + 'name' => admin_setting('app_name', 'Notification Service'), 'url' => admin_setting('app_url'), 'content' => "主题:{$ticket->subject}\r\n回复内容:{$ticketMessage->message}" ]