diff --git a/.gitignore b/.gitignore index 74ee413..2a3e47c 100755 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ compose.split.yaml /macos-client/.build/ /macos-client/.swiftpm/ +/storage/app/private/ diff --git a/app/Console/Commands/PollMailInbox.php b/app/Console/Commands/PollMailInbox.php new file mode 100644 index 0000000..886c6ec --- /dev/null +++ b/app/Console/Commands/PollMailInbox.php @@ -0,0 +1,32 @@ +get()) { return self::SUCCESS; } + try { + $this->info('New incoming messages: ' . $inbox->poll()); + return self::SUCCESS; + } catch (\Throwable $e) { + $this->error('Inbox polling failed (' . get_class($e) . '). Check the IMAP settings/extension.'); + return self::FAILURE; + } finally { + $lock->release(); + } + } +} diff --git a/app/Console/Commands/ReconcileMail.php b/app/Console/Commands/ReconcileMail.php new file mode 100644 index 0000000..1f38a76 --- /dev/null +++ b/app/Console/Commands/ReconcileMail.php @@ -0,0 +1,59 @@ +where('status', 'pending')->orderBy('id')->limit(50)->get() + ->each(function ($event) use ($events) { + try { $events->apply($event, true); } catch (\Throwable $e) { /* retain for next poll */ } + }); + if (Settings::get('resend_enabled') && Settings::get('resend_key')) { + $messages = MailMessage::where('direction', 'out')->where('channel', 'resend') + ->whereNotNull('provider_id')->whereIn('status', ['submitted', 'delayed']) + ->where('created_at', '>', now()->subDays(7)) + ->orderBy('updated_at')->orderBy('id')->limit(10)->get(); + foreach ($messages as $message) { + try { $events->refreshStatus($message); } + catch (\Throwable $e) { break; } // Preserve status and retry next cycle, including rate limits. + if (!app()->runningUnitTests()) { usleep(600000); } + } + } + } + MailMessage::where('direction', 'out')->whereNotNull('payload')->where('updated_at', '<', now()->subMinutes(2)) + ->when(!$enabled, fn ($q) => $q->whereIn('purpose', ['service_expired', 'traffic_reminder', 'expiry_reminder', 'automatic_broadcast'])) + ->where(function ($query) { + $query->where('status', 'pending')->orWhere('status', 'sending') + ->orWhere(fn ($q) => $q->where('status', 'unknown')->where('channel', 'resend')); + })->orderBy('id')->limit(100)->get()->each(function ($message) { + if ($message->attempts >= 6 && $message->status === 'unknown') { + $message->update(['payload' => null]); + return; + } + if ($message->attempts >= 6 && $message->status === 'pending') { + $message->update(['status' => 'failed', 'error_code' => 'mail_retry_exhausted', 'payload' => null]); + return; + } + SendMailMessageJob::dispatch($message->id); + }); + MailMessage::where('direction', 'in')->where('created_at', '<', now()->subDays(30))->update(['payload' => null]); + // Verification/login content must not remain indefinitely in encrypted storage. + MailMessage::whereNotNull('payload')->where('direction', 'out')->where('first_attempt_at', '<', now()->subHours(23)) + ->update(['payload' => null]); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ReportMailUsage.php b/app/Console/Commands/ReportMailUsage.php new file mode 100644 index 0000000..ca59cdc --- /dev/null +++ b/app/Console/Commands/ReportMailUsage.php @@ -0,0 +1,26 @@ +snapshot(now()->utc()->startOfDay()); + MailDailyReport::where(function ($query) { + $query->whereIn('status', ['pending', 'failed'])->orWhere(fn ($q) => $q->where('status', 'sending')->where('updated_at', '<', now()->subMinutes(2))); + })->where('attempts', '<', 3) + ->orderBy('id')->limit(10)->get()->each(fn ($report) => $reports->send($report)); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SendExpiredSubscriptionMail.php b/app/Console/Commands/SendExpiredSubscriptionMail.php new file mode 100644 index 0000000..9417c1d --- /dev/null +++ b/app/Console/Commands/SendExpiredSubscriptionMail.php @@ -0,0 +1,22 @@ +warn('自动邮件档位暂不可读取'); return self::FAILURE; } + if ($state['level'] !== 2) { return self::SUCCESS; } + $this->info('套餐到期通知已派发:' . $notifier->queueDue()); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SendRemindMail.php b/app/Console/Commands/SendRemindMail.php index 5fdb2d1..ce18d82 100644 --- a/app/Console/Commands/SendRemindMail.php +++ b/app/Console/Commands/SendRemindMail.php @@ -31,6 +31,11 @@ class SendRemindMail extends Command */ public function handle(): int { + $state = \App\Services\MailFlow\AutomaticMailPolicy::state(); + if (!$state || $state['level'] !== 1) { + $this->info('当前档位不派发流量及到期前提醒'); + return 0; + } if (!admin_setting('remind_mail_enable', false)) { $this->warn('邮件提醒功能未启用'); return 0; diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 447440a..eee3a14 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -40,6 +40,12 @@ class Kernel extends ConsoleKernel $schedule->command('reset:log')->daily()->onOneServer(); // send $schedule->command('send:remindMail', ['--force'])->dailyAt('11:30')->onOneServer(); + $schedule->command('send:expiredSubscriptionMail')->everyMinute()->onOneServer()->withoutOverlapping(10); + // Mail flow stays disabled until explicitly configured. + $schedule->command('mail:poll-inbox')->everyMinute()->onOneServer()->withoutOverlapping(30)->runInBackground(); + $schedule->command('mail:reconcile')->everyFiveMinutes()->onOneServer()->withoutOverlapping(10)->runInBackground(); + $schedule->command('mail:report-usage')->timezone('Asia/Shanghai')->dailyAt('08:00')->onOneServer()->withoutOverlapping(10); + $schedule->command('mail:report-usage')->everyFiveMinutes()->onOneServer()->withoutOverlapping(10); // horizon metrics $schedule->command('horizon:snapshot')->everyFiveMinutes()->onOneServer(); // cleanup stale online_count (GC for Redis TTL expiration) diff --git a/app/Http/Controllers/V1/Guest/ResendController.php b/app/Http/Controllers/V1/Guest/ResendController.php new file mode 100644 index 0000000..79306df --- /dev/null +++ b/app/Http/Controllers/V1/Guest/ResendController.php @@ -0,0 +1,26 @@ +getContent(); + abort_if(strlen($body) > 262144, 413); + $id = (string) $request->header('svix-id'); + abort_unless($signature->valid($body, $id, (string) $request->header('svix-timestamp'), + (string) $request->header('svix-signature'), (string) Settings::get('resend_webhook_secret')), 401); + $event = json_decode($body, true); + abort_unless(is_array($event), 400); + $events->receive($id, $event); + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/V1/Passport/CommController.php b/app/Http/Controllers/V1/Passport/CommController.php index 663badf..d032101 100644 --- a/app/Http/Controllers/V1/Passport/CommController.php +++ b/app/Http/Controllers/V1/Passport/CommController.php @@ -50,6 +50,8 @@ class CommController extends Controller 'email' => $email, 'subject' => $subject, 'template_name' => 'verify', + 'purpose' => 'verification', + 'expires_at' => now()->addMinutes(5)->toDateTimeString(), 'template_value' => [ 'name' => admin_setting('app_name', 'XBoard'), 'code' => $code, diff --git a/app/Http/Controllers/V2/Admin/ConfigController.php b/app/Http/Controllers/V2/Admin/ConfigController.php index 24ae512..9f09c86 100644 --- a/app/Http/Controllers/V2/Admin/ConfigController.php +++ b/app/Http/Controllers/V2/Admin/ConfigController.php @@ -39,6 +39,7 @@ class ConfigController extends Controller 'email' => $request->user()->email, 'subject' => 'This is xboard test email', 'template_name' => 'notify', + 'purpose' => 'admin_test', 'template_value' => [ 'name' => admin_setting('app_name', 'XBoard'), 'content' => 'This is xboard test email', @@ -154,6 +155,8 @@ class ConfigController extends Controller 'email_encryption' => admin_setting('email_encryption'), 'email_from_address' => admin_setting('email_from_address'), 'remind_mail_enable' => (bool) admin_setting('remind_mail_enable', false), + 'automatic_mail_level' => \App\Services\MailFlow\AutomaticMailPolicy::state()['level'] ?? null, + 'mail_flow' => \App\Services\MailFlow\Settings::publicSettings(), ], 'telegram' => [ 'telegram_bot_enable' => (bool) admin_setting('telegram_bot_enable', 0), @@ -222,6 +225,22 @@ class ConfigController extends Controller ]; foreach ($data as $k => $v) { + if ($k === 'automatic_mail_level') { + \App\Services\MailFlow\AutomaticMailPolicy::save((int) $v); + continue; + } + if ($k === 'mail_flow') { + $previous = (array) admin_setting('mail_flow', []); + foreach (['resend_key', 'resend_webhook_secret', 'imap_password'] as $secret) { + if (array_key_exists($secret, $v) && ($v[$secret] === null || $v[$secret] === '')) { + unset($v[$secret]); // Blank means keep the saved secret. + } elseif (!empty($v[$secret])) { + $v[$secret] = 'encrypted:' . \Illuminate\Support\Facades\Crypt::encryptString($v[$secret]); + } + } + $v = array_replace($previous, $v); + \Illuminate\Support\Facades\Cache::forget('mailflow:resend:usage'); + } if (isset($templateKeys[$k])) { SubscribeTemplate::setContent($templateKeys[$k], $v); continue; diff --git a/app/Http/Controllers/V2/Admin/MailFlowController.php b/app/Http/Controllers/V2/Admin/MailFlowController.php new file mode 100644 index 0000000..2fb396f --- /dev/null +++ b/app/Http/Controllers/V2/Admin/MailFlowController.php @@ -0,0 +1,34 @@ +validate(['id' => 'required|integer|min:1']); + $message = MailMessage::where('direction', 'in')->findOrFail($data['id']); + return $this->success(['email' => $message->email, 'subject' => $message->subject, 'text' => $message->payload['text'] ?? '']); + } + + public function status() + { + $ready = Schema::hasTable('v2_mail_messages') && Schema::hasTable('v2_mail_quota_buckets') && Schema::hasTable('v2_mail_daily_reports'); + return $this->success([ + 'automatic_mail_level' => \App\Services\MailFlow\AutomaticMailPolicy::state()['level'] ?? null, + 'settings' => Settings::publicSettings(), 'database_ready' => $ready, + 'imap_available' => function_exists('imap_open'), + 'quota' => $ready ? MailQuotaBucket::where('resets_at', '>', now())->get() : [], + 'messages' => $ready ? MailMessage::whereIn('direction', ['in', 'out'])->latest('id')->limit(50) + ->get(['id', 'direction', 'purpose', 'channel', 'email', 'status', 'error_code', 'fallback_reason', 'created_at']) : [], + 'reports' => $ready ? MailDailyReport::latest('id')->limit(7)->get() : [], + ]); + } +} diff --git a/app/Http/Controllers/V2/Admin/MailTemplateController.php b/app/Http/Controllers/V2/Admin/MailTemplateController.php index 8561b8b..2bcbdbd 100644 --- a/app/Http/Controllers/V2/Admin/MailTemplateController.php +++ b/app/Http/Controllers/V2/Admin/MailTemplateController.php @@ -107,6 +107,7 @@ class MailTemplateController extends Controller 'email' => $email, 'subject' => $this->getTestSubject($name), 'template_name' => $name, + 'purpose' => 'admin_test', 'template_value' => $testVars, ]); @@ -127,6 +128,7 @@ class MailTemplateController extends Controller 'verify' => "{$appName} - 验证码测试", 'notify' => "{$appName} - 通知测试", 'remindExpire' => "{$appName} - 到期提醒测试", + 'serviceExpired' => "{$appName} - 套餐已到期测试", 'remindTraffic' => "{$appName} - 流量提醒测试", 'mailLogin' => "{$appName} - 登录链接测试", default => "{$appName} - 邮件测试", @@ -173,6 +175,7 @@ class MailTemplateController extends Controller 'verify' => "{$appName} - 邮箱验证码", 'notify' => "{$appName} - 站点通知", 'remindExpire' => "{$appName} - 服务即将到期", + 'serviceExpired' => "{$appName} - 套餐已到期", 'remindTraffic' => "{$appName} - 流量使用提醒", 'mailLogin' => "{$appName} - 邮件登录", default => "{$appName}", @@ -258,6 +261,7 @@ HTML; 'verify' => $layout('邮箱验证码', '您的验证码是:{{code}},请在 5 分钟内进行验证。如果该验证码不为您本人申请,请无视。'), 'notify' => $layout('网站通知', '{{content}}'), 'remindExpire' => $layout('服务到期提醒', '您的服务即将在24小时内到期,如需继续使用请及时续费。'), + 'serviceExpired' => $layout('套餐已到期', '您的套餐已到期。如需继续使用,请登录网站续费。'), 'remindTraffic' => $layout('流量使用提醒', '您的流量使用已达到80%,请注意流量使用情况。'), 'mailLogin' => $layout('登入到{{name}}', '您正在登入到{{name}}, 请在 5 分钟内点击下方链接进行登入。如果您未授权该登入请求,请无视。{{link}}'), default => $layout('通知', '{{content}}'), diff --git a/app/Http/Controllers/V2/Admin/UserController.php b/app/Http/Controllers/V2/Admin/UserController.php index 48e7fb8..81214b8 100644 --- a/app/Http/Controllers/V2/Admin/UserController.php +++ b/app/Http/Controllers/V2/Admin/UserController.php @@ -638,6 +638,8 @@ class UserController extends Controller 'email' => $user->email, 'subject' => $subject, 'template_name' => 'notify', + 'purpose' => 'broadcast', + 'user_id' => $user->id, 'template_value' => $templateValue ], 'send_email_mass')); } diff --git a/app/Http/Requests/Admin/ConfigSave.php b/app/Http/Requests/Admin/ConfigSave.php index 4a8f056..b58f90e 100755 --- a/app/Http/Requests/Admin/ConfigSave.php +++ b/app/Http/Requests/Admin/ConfigSave.php @@ -67,6 +67,29 @@ class ConfigSave extends FormRequest 'email_encryption' => '', 'email_from_address' => '', 'remind_mail_enable' => '', + 'automatic_mail_level' => 'sometimes|integer|in:1,2,3', + 'mail_flow' => 'sometimes|array:enabled,resend_enabled,resend_key,resend_from,resend_webhook_secret,reply_to,daily_limit,monthly_limit,inbound_enabled,imap_host,imap_port,imap_username,imap_password,imap_folder,auto_reply_enabled,auto_reply_subject,auto_reply_text,report_enabled,master_url,master_bot_code,master_chat_code', + 'mail_flow.enabled' => 'sometimes|boolean', + 'mail_flow.resend_enabled' => 'sometimes|boolean', + 'mail_flow.resend_key' => 'nullable|string|max:256', + 'mail_flow.resend_from' => 'nullable|email|max:254', + 'mail_flow.resend_webhook_secret' => 'nullable|string|max:256', + 'mail_flow.reply_to' => 'nullable|email|max:254', + 'mail_flow.daily_limit' => 'sometimes|integer|min:1|max:100', + 'mail_flow.monthly_limit' => 'sometimes|integer|min:1|max:3000', + 'mail_flow.inbound_enabled' => 'sometimes|boolean', + 'mail_flow.imap_host' => 'nullable|string|regex:/^[a-zA-Z0-9.-]+$/|max:253', + 'mail_flow.imap_port' => 'sometimes|integer|min:1|max:65535', + 'mail_flow.imap_username' => 'nullable|string|max:254', + 'mail_flow.imap_password' => 'nullable|string|max:1024', + 'mail_flow.imap_folder' => 'sometimes|string|regex:/^[a-zA-Z0-9_ .\\/-]+$/|max:100', + 'mail_flow.auto_reply_enabled' => 'sometimes|boolean', + 'mail_flow.auto_reply_subject' => 'sometimes|string|max:200', + 'mail_flow.auto_reply_text' => 'sometimes|string|max:5000', + 'mail_flow.report_enabled' => 'sometimes|boolean', + 'mail_flow.master_url' => 'nullable|url:https|max:500', + 'mail_flow.master_bot_code' => 'nullable|string|max:100', + 'mail_flow.master_chat_code' => 'nullable|string|max:100', // telegram 'telegram_bot_enable' => '', 'telegram_bot_token' => '', @@ -123,6 +146,49 @@ class ConfigSave extends FormRequest return self::RULES; } + public function after(): array + { + return [function ($validator) { + if ((int) $this->input('automatic_mail_level') === 2 && !\Illuminate\Support\Facades\Schema::hasTable('v2_mail_messages')) { + $validator->errors()->add('automatic_mail_level', '二档到期通知需要先执行邮件记录数据库迁移。'); + } + if (!$this->has('mail_flow')) { return; } + $settings = array_replace(\App\Services\MailFlow\Settings::all(), (array) $this->input('mail_flow')); + foreach (['resend_key', 'resend_webhook_secret', 'imap_password'] as $key) { + if (empty($settings[$key])) { $settings[$key] = \App\Services\MailFlow\Settings::get($key); } + } + if (empty($settings['enabled'])) { return; } + foreach (['v2_mail_messages', 'v2_mail_quota_buckets', 'v2_mail_daily_reports'] as $table) { + if (!\Illuminate\Support\Facades\Schema::hasTable($table)) { + $validator->errors()->add('mail_flow.enabled', '请先执行邮件功能数据库迁移。'); + break; + } + } + $required = []; + if (!empty($settings['resend_enabled'])) { + $required = array_merge($required, ['resend_key', 'resend_from', 'resend_webhook_secret']); + } + if (!empty($settings['inbound_enabled'])) { + $required = array_merge($required, ['imap_host', 'imap_username', 'imap_password']); + if (!function_exists('imap_open')) { + $validator->errors()->add('mail_flow.inbound_enabled', '请先安装 PHP IMAP 扩展。'); + } + } + if (!empty($settings['auto_reply_enabled'])) { + $required[] = 'reply_to'; + if (empty($settings['inbound_enabled'])) { + $validator->errors()->add('mail_flow.auto_reply_enabled', '请先启用常规邮箱收件。'); + } + } + if (!empty($settings['report_enabled'])) { + $required = array_merge($required, ['master_url', 'master_bot_code', 'master_chat_code']); + } + foreach (array_unique($required) as $key) { + if (empty($settings[$key])) { $validator->errors()->add('mail_flow.' . $key, '请补齐此项配置。'); } + } + }]; + } + public function messages() { // illiteracy prompt diff --git a/app/Http/Routes/V1/GuestRoute.php b/app/Http/Routes/V1/GuestRoute.php index 3c4f571..f76ea8b 100644 --- a/app/Http/Routes/V1/GuestRoute.php +++ b/app/Http/Routes/V1/GuestRoute.php @@ -14,6 +14,7 @@ class GuestRoute $router->group([ 'prefix' => 'guest' ], function ($router) { + $router->post('/resend/webhook', [\App\Http\Controllers\V1\Guest\ResendController::class, 'webhook']); // Plan $router->get('/plan/fetch', [PlanController::class, 'fetch']); // Telegram diff --git a/app/Http/Routes/V2/AdminRoute.php b/app/Http/Routes/V2/AdminRoute.php index f94952c..95a79d5 100644 --- a/app/Http/Routes/V2/AdminRoute.php +++ b/app/Http/Routes/V2/AdminRoute.php @@ -35,6 +35,8 @@ class AdminRoute 'prefix' => 'config' ], function ($router) { $router->get('/fetch', [ConfigController::class, 'fetch']); + $router->get('/mailFlowIncoming', [\App\Http\Controllers\V2\Admin\MailFlowController::class, 'incoming']); + $router->get('/mailFlowStatus', [\App\Http\Controllers\V2\Admin\MailFlowController::class, 'status']); $router->post('/save', [ConfigController::class, 'save']); $router->get('/getEmailTemplate', [ConfigController::class, 'getEmailTemplate']); $router->get('/getThemeTemplate', [ConfigController::class, 'getThemeTemplate']); diff --git a/app/Jobs/SendEmailJob.php b/app/Jobs/SendEmailJob.php index d998f41..4e2fa99 100644 --- a/app/Jobs/SendEmailJob.php +++ b/app/Jobs/SendEmailJob.php @@ -15,7 +15,8 @@ class SendEmailJob implements ShouldQueue protected $params; public $tries = 3; - public $timeout = 10; + public $timeout = 30; + public $backoff = [15, 60, 180]; /** * Create a new job instance. * @@ -24,6 +25,7 @@ class SendEmailJob implements ShouldQueue public function __construct($params, $queue = 'send_email') { $this->onQueue($queue); + $params['message_key'] ??= (string) \Illuminate\Support\Str::uuid(); $this->params = $params; } @@ -34,9 +36,10 @@ class SendEmailJob implements ShouldQueue */ public function handle() { + $this->params['message_key'] ??= $this->job?->uuid() ?? (string) \Illuminate\Support\Str::uuid(); $mailLog = MailService::sendEmail($this->params); - if ($mailLog['error']) { - $this->release(); //发送失败将触发重试 + if ($mailLog['retryable'] ?? false) { + $this->release($this->backoff[min($this->attempts() - 1, count($this->backoff) - 1)]); } } } diff --git a/app/Jobs/SendMailMessageJob.php b/app/Jobs/SendMailMessageJob.php new file mode 100644 index 0000000..3a359b4 --- /dev/null +++ b/app/Jobs/SendMailMessageJob.php @@ -0,0 +1,42 @@ +onQueue('send_email'); } + + public function handle(Dispatcher $dispatcher): void + { + $message = MailMessage::find($this->messageId); + if (!$message || !$message->payload || $message->direction !== 'out') { + return; + } + if (!\App\Services\MailFlow\Settings::get('enabled') && $message->purpose !== 'service_expired' + && !\App\Services\MailFlow\AutomaticMailPolicy::decision($message->purpose)) { return; } + $payload = $message->payload; + $result = isset($payload['email_params']) + ? \App\Services\MailService::sendEmail($payload['email_params']) + : $dispatcher->send(['persisted_message' => true, 'message_key' => $message->message_key, 'email' => $message->email, + 'purpose' => $message->purpose], $message->subject, $payload['html']); + if ($result['retryable'] ?? false) { + $this->release(60); + } elseif (isset($payload['email_params']) && ($result['status'] ?? null) === 'failed') { + // Rendering/recipient failures can occur before Dispatcher has touched the persisted task. + MailMessage::whereKey($message->id)->where('status', 'pending') + ->update(['status' => 'failed', 'error_code' => $result['error'], 'payload' => null]); + } + } +} diff --git a/app/Models/MailDailyReport.php b/app/Models/MailDailyReport.php new file mode 100644 index 0000000..f27f10d --- /dev/null +++ b/app/Models/MailDailyReport.php @@ -0,0 +1,12 @@ + 'array', 'submitted_at' => 'datetime']; +} diff --git a/app/Models/MailMessage.php b/app/Models/MailMessage.php new file mode 100644 index 0000000..a1a33d8 --- /dev/null +++ b/app/Models/MailMessage.php @@ -0,0 +1,17 @@ + 'encrypted:array', 'reservation' => 'array', + 'first_attempt_at' => 'datetime', 'expires_at' => 'datetime', + 'submitted_at' => 'datetime', 'delivered_at' => 'datetime', 'provider_event_at' => 'datetime', + ]; +} diff --git a/app/Models/MailQuotaBucket.php b/app/Models/MailQuotaBucket.php new file mode 100644 index 0000000..6577cef --- /dev/null +++ b/app/Models/MailQuotaBucket.php @@ -0,0 +1,12 @@ + 'datetime', 'synced_at' => 'datetime']; +} diff --git a/app/Models/MailTemplate.php b/app/Models/MailTemplate.php index c726add..a0c0752 100644 --- a/app/Models/MailTemplate.php +++ b/app/Models/MailTemplate.php @@ -29,6 +29,11 @@ class MailTemplate extends Model 'required_vars' => [], 'optional_vars' => ['name', 'url'], ], + 'serviceExpired' => [ + 'label' => '套餐已到期(二档)', + 'required_vars' => [], + 'optional_vars' => ['name', 'url'], + ], 'remindTraffic' => [ 'label' => '流量提醒', 'required_vars' => [], diff --git a/app/Services/Auth/MailLinkService.php b/app/Services/Auth/MailLinkService.php index 443c6d8..1d568b4 100644 --- a/app/Services/Auth/MailLinkService.php +++ b/app/Services/Auth/MailLinkService.php @@ -64,6 +64,9 @@ class MailLinkService 'name' => admin_setting('app_name', 'XBoard') ]), 'template_name' => 'mailLogin', + 'purpose' => 'login_link', + 'user_id' => $user->id, + 'expires_at' => now()->addMinutes(5)->toDateTimeString(), 'template_value' => [ 'name' => admin_setting('app_name', 'XBoard'), 'link' => $link, diff --git a/app/Services/MailFlow/AutoReplyService.php b/app/Services/MailFlow/AutoReplyService.php new file mode 100644 index 0000000..d31a029 --- /dev/null +++ b/app/Services/MailFlow/AutoReplyService.php @@ -0,0 +1,60 @@ +email); + $own = [strtolower((string) Settings::get('reply_to')), strtolower((string) Settings::get('imap_username')), + strtolower((string) admin_setting('email_from_address', config('mail.from.address')))]; + if (!filter_var($sender, FILTER_VALIDATE_EMAIL) || in_array($sender, $own, true) + || preg_match('/(?:mailer-daemon|postmaster|no-?reply)@/i', $sender) + || (isset($headers['auto-submitted']) && strtolower($headers['auto-submitted']) !== 'no') + || isset($headers['list-id']) || isset($headers['x-auto-response-suppress']) + || in_array(strtolower($headers['precedence'] ?? ''), ['bulk', 'list', 'junk'], true) + || trim($headers['return-path'] ?? '') === '<>') { + return null; + } + // Binding is optional; never send to an arbitrary incoming Reply-To address. + $user = User::byEmail($sender)->first(); + if ($user?->banned) { + return null; + } + if (MailMessage::where('parent_id', $incoming->id)->exists()) { + return null; + } + $day = now()->timezone('Asia/Shanghai')->startOfDay(); + if (MailMessage::where('direction', 'out')->where('purpose', 'auto_reply')->where('email', $sender) + ->where('created_at', '>=', $day->copy()->timezone(config('app.timezone', 'UTC')))->exists()) { + return null; + } + $outHeaders = ['Auto-Submitted' => 'auto-replied', 'X-Auto-Response-Suppress' => 'All']; + if (preg_match('/^<[^<>\r\n]{1,250}>$/', $headers['message-id'] ?? '')) { + $outHeaders['In-Reply-To'] = $headers['message-id']; + $outHeaders['References'] = $headers['message-id']; + } + $html = view('mail.default.notify', array_replace([ + 'name' => admin_setting('app_name', 'dpl'), 'url' => admin_setting('app_url', config('app.url')), + 'content' => e((string) Settings::get('auto_reply_text')), + ], Branding::values()))->render(); + // The unique key reserves one reply per sender/day, including concurrent arrivals. + $reply = MailMessage::firstOrCreate(['message_key' => 'reply:' . $day->format('Y-m-d') . ':' . hash('sha256', $sender)], [ + 'parent_id' => $incoming->id, + 'purpose' => 'auto_reply', 'user_id' => $user?->id, 'email' => $sender, + 'subject' => Settings::get('auto_reply_subject'), + 'expires_at' => $day->copy()->addDay()->timezone(config('app.timezone', 'UTC')), + 'payload' => ['to' => $sender, 'subject' => Settings::get('auto_reply_subject'), + 'html' => $html, 'attachments' => Branding::attachments(), + 'reply_to' => Settings::get('reply_to'), 'headers' => $outHeaders], + ]); + return $reply->wasRecentlyCreated ? $reply : null; + } +} diff --git a/app/Services/MailFlow/AutomaticMailPolicy.php b/app/Services/MailFlow/AutomaticMailPolicy.php new file mode 100644 index 0000000..802f3d5 --- /dev/null +++ b/app/Services/MailFlow/AutomaticMailPolicy.php @@ -0,0 +1,83 @@ +first()?->value; + if ($state === null) { return ['level' => 1, 'since' => null]; } + if (!is_array($state) || !in_array($state['level'] ?? null, [1, 2, 3], true)) { return null; } + return $state; + } catch (\Throwable $e) { + return null; + } + } + + public static function save(int $level): void + { + if (!in_array($level, [1, 2, 3], true)) { throw new \InvalidArgumentException('Invalid automatic mail level'); } + Cache::lock('automatic-mail-policy:save', 10)->block(5, function () use ($level) { + DB::transaction(function () use ($level) { + $previous = self::state(); + if ($previous === null) { throw new \RuntimeException('automatic_mail_policy_unavailable'); } + Setting::createOrUpdate('automatic_mail_policy', [ + 'level' => $level, + 'since' => $level === 2 ? (($previous['level'] === 2 ? $previous['since'] : null) ?? now()->timestamp) : null, + ]); + }); + }); + Cache::store('redis')->forget(\App\Support\Setting::CACHE_KEY); + } + + public static function purpose(array $params): string + { + // Compatibility for older queued jobs. Never infer notify: it also serves replies/tests. + return $params['purpose'] ?? match ($params['template_name'] ?? '') { + 'verify' => 'verification', 'mailLogin' => 'login_link', + 'remindTraffic' => 'traffic_reminder', 'remindExpire' => 'expiry_reminder', + default => 'other', + }; + } + + public static function decision(string $purpose, array $context = []): ?array + { + if (!in_array($purpose, ['traffic_reminder', 'expiry_reminder', 'automatic_broadcast', 'service_expired'], true)) { + return null; // Includes manual broadcast, all responses and explicitly classified admin tests. + } + $state = self::state(); + if ($state === null) { + return ['status' => 'pending', 'error' => 'automatic_mail_policy_unavailable', 'retryable' => true]; + } + $allowed = $purpose === 'service_expired' ? $state['level'] === 2 : $state['level'] === 1; + if (!$allowed) { return self::blocked('automatic_mail_level_blocked'); } + if ($purpose === 'service_expired') { + $due = (int) ($context['subscription_expired_at'] ?? 0); + try { + $user = User::find($context['user_id'] ?? 0); + $reminders = Setting::where('name', 'remind_mail_enable')->first()?->value; + } catch (\Throwable $e) { + return ['status' => 'pending', 'error' => 'subscription_expiry_check_unavailable', 'retryable' => true]; + } + if (!$state['since'] || $due <= $state['since'] || $due > now()->timestamp + || !$reminders || !$user || !$user->remind_expire || $user->banned || !$user->plan_id + || (int) $user->expired_at !== $due) { + return self::blocked('subscription_expiry_no_longer_valid'); + } + } + return null; + } + + private static function blocked(string $error): array + { + return ['status' => 'policy_blocked', 'error' => $error, 'retryable' => false]; + } +} diff --git a/app/Services/MailFlow/Branding.php b/app/Services/MailFlow/Branding.php new file mode 100644 index 0000000..cae49e5 --- /dev/null +++ b/app/Services/MailFlow/Branding.php @@ -0,0 +1,22 @@ + admin_setting('app_url', config('app.url')), + 'logo_src' => self::attachments() ? 'cid:site-logo@dpl' : null]; + } + + public static function attachments(): array + { + $path = config('mailflow.logo_path'); + if (!$path || !is_file($path) || filesize($path) > 512 * 1024) { return []; } + $bytes = file_get_contents($path); + if (!str_starts_with($bytes, "\x89PNG\r\n\x1a\n")) { return []; } + return [['filename' => 'site-logo.png', 'content' => base64_encode($bytes), + 'content_type' => 'image/png', 'content_id' => 'site-logo@dpl']]; + } +} diff --git a/app/Services/MailFlow/ConventionalTransport.php b/app/Services/MailFlow/ConventionalTransport.php new file mode 100644 index 0000000..b480b56 --- /dev/null +++ b/app/Services/MailFlow/ConventionalTransport.php @@ -0,0 +1,55 @@ +html($payload['html'], function ($message) use ($payload, $from, $name) { + $message->from($from, $name)->to($payload['to'])->subject($payload['subject']); + if (!empty($payload['reply_to'])) { + $message->replyTo($payload['reply_to']); + } + foreach ($payload['attachments'] ?? [] as $attachment) { + $bytes = base64_decode($attachment['content'], true); + if ($bytes === false) { throw new \RuntimeException('mail_attachment_invalid'); } + $part = new \Symfony\Component\Mime\Part\DataPart($bytes, $attachment['filename'], $attachment['content_type'] ?? null); + if (!empty($attachment['content_id'])) { $part->asInline()->setContentId($attachment['content_id']); } + $message->getSymfonyMessage()->addPart($part); + } + foreach ($payload['headers'] ?? [] as $key => $value) { + if (in_array(strtolower($key), ['in-reply-to', 'references'], true)) { + $message->getSymfonyMessage()->getHeaders()->addIdHeader($key, trim($value, '<>')); + } else { + $message->getSymfonyMessage()->getHeaders()->addTextHeader($key, $value); + } + } + }); + if (!$sent) { + throw new \RuntimeException('mail_send_cancelled'); + } + return [ + 'status' => in_array($driver, ['log', 'array'], true) ? 'simulated' : 'submitted', + 'provider_id' => $sent->getMessageId(), + ]; + } +} diff --git a/app/Services/MailFlow/DailyReportService.php b/app/Services/MailFlow/DailyReportService.php new file mode 100644 index 0000000..77f1849 --- /dev/null +++ b/app/Services/MailFlow/DailyReportService.php @@ -0,0 +1,99 @@ +copy()->subDay(); + $report = MailDailyReport::where('report_date', $start->toDateString())->first(); + if ($report) { return $report; } + $rows = MailMessage::whereIn('direction', ['in', 'out']) + ->whereRaw('COALESCE(submitted_at, first_attempt_at, created_at) >= ?', [$start->copy()->setTimezone(config('app.timezone'))->toDateTimeString()]) + ->whereRaw('COALESCE(submitted_at, first_attempt_at, created_at) < ?', [$end->copy()->setTimezone(config('app.timezone'))->toDateTimeString()])->get(['direction', 'purpose', 'channel', 'status', 'fallback_reason']); + $totals = []; + foreach ($rows as $row) { + $key = $row->direction . '/' . ($row->channel ?: ($row->status === 'policy_blocked' ? 'policy' : 'pending')) . '/' . $row->status; + $totals[$key] = ($totals[$key] ?? 0) + 1; + } + $usage = null; + if (Settings::get('resend_enabled') && Settings::get('resend_key')) { + try { $usage = $this->resend->usage(); } catch (\Throwable $e) { /* report local records with an explicit unavailable marker */ } + } + $summary = [ + 'resend_usage' => $usage, + 'resend_usage_checked_at' => $usage ? now()->toIso8601String() : null, + 'from' => $start->toIso8601String(), 'to' => $end->toIso8601String(), + 'counts' => $totals, 'fallbacks' => $rows->whereNotNull('fallback_reason')->count(), + 'backlog' => MailMessage::where('direction', 'out')->whereIn('status', ['pending', 'sending', 'unknown'])->count(), + 'quota' => MailQuotaBucket::where('resets_at', '>', now())->get(['period', 'limit', 'used', 'reserved', 'synced_at'])->toArray(), + ]; + return MailDailyReport::firstOrCreate(['report_date' => $start->toDateString()], ['summary' => $summary]); + } + + public function send(MailDailyReport $report): void + { + $lock = Cache::lock('mailflow:report:' . $report->id, 60); + if (!$lock->get()) { return; } + try { + $report->refresh(); + if ($report->status === 'sending') { + $report->update(['status' => 'unknown', 'error_code' => 'master_worker_interrupted']); + } + if (!in_array($report->status, ['pending', 'failed'], true)) { return; } + $url = (string) Settings::get('master_url'); + if (!str_starts_with($url, 'https://') || !filter_var($url, FILTER_VALIDATE_URL) + || !Settings::get('master_bot_code') || !Settings::get('master_chat_code')) { + $report->update(['error_code' => 'master_configuration_required']); + return; + } + $report->increment('attempts'); + $report->update(['status' => 'sending']); + $summary = $report->summary; + $text = '📧 ' . admin_setting('app_name', 'dpl') . ' 邮件日报(北京时间八点结算)'; + foreach ($summary['counts'] as $key => $count) { + [$direction, $channel, $status] = explode('/', $key); + $labels = ['out' => '发送', 'in' => '收件', 'policy' => '邮件策略', 'resend' => 'Resend', 'conventional' => '常规邮箱', 'pending' => '待处理', 'submitted' => '已提交', 'delivered' => '已投递', 'delayed' => '投递延迟', 'bounced' => '退信', 'complained' => '投诉', 'suppressed' => '停止投递', 'failed' => '失败', 'unknown' => '结果未知', 'sending' => '提交中', 'received' => '已接收', 'simulated' => '模拟发送', 'expired' => '已过期', 'policy_blocked' => '因档位或套餐状态取消']; + $text .= "\n" . ($labels[$direction] ?? $direction) . ' / ' . ($labels[$channel] ?? $channel) . ' / ' . ($labels[$status] ?? $status) . ':' . $count; + } + $text .= "\n回退常规邮箱:" . $summary['fallbacks'] . "\n待处理/结果未知:" . $summary['backlog']; + if ($summary['resend_usage'] ?? null) { + $monthly = $summary['resend_usage']['monthly'] ?? []; + if (isset($monthly['used'], $monthly['limit'])) { + $text .= "\nResend 官方本月已用:" . $monthly['used'] . ' / ' . $monthly['limit']; + } + } else { + $text .= "\nResend 官方额度未能实时核对,以下为本地记录。"; + } + foreach ($summary['quota'] as $quota) { + $text .= "\nResend " . $quota['period'] . ' 保守剩余额度:' . max(0, $quota['limit'] - $quota['used'] - $quota['reserved']); + } + try { + // Master currently exposes no idempotency guarantee. An uncertain response is not auto-retried. + $response = Http::connectTimeout(3)->timeout(8)->post(rtrim($url, '/') . '/api/telegram/post', [ + 'sBotCode' => Settings::get('master_bot_code'), 'sChatCode' => Settings::get('master_chat_code'), + 'sMsg' => $text, 'enableMasterQueue' => false, + ]); + } catch (\Throwable $e) { + $report->update(['status' => 'unknown', 'error_code' => 'master_transport_unknown']); + return; + } + if ($response->successful() && $response->json('isDone') === true) { + $report->update(['status' => 'submitted', 'submitted_at' => now(), 'error_code' => null]); + } else { + $report->update(['status' => 'unknown', 'error_code' => 'master_response_unknown']); + } + } finally { + $lock->release(); + } + } +} diff --git a/app/Services/MailFlow/DeliveryEvents.php b/app/Services/MailFlow/DeliveryEvents.php new file mode 100644 index 0000000..2f53c48 --- /dev/null +++ b/app/Services/MailFlow/DeliveryEvents.php @@ -0,0 +1,124 @@ + 'submitted', 'email.delivered' => 'delivered', + 'email.delivery_delayed' => 'delayed', 'email.bounced' => 'bounced', + 'email.failed' => 'failed', 'email.complained' => 'complained', 'email.suppressed' => 'suppressed', + ]; + + public function __construct(private QuotaService $quota, private ResendClient $client) {} + + public function refreshStatus(MailMessage $message): void + { + if ($message->channel !== 'resend' || !$message->provider_id + || !in_array($message->status, ['submitted', 'delayed'], true)) { + return; + } + $email = $this->client->retrieve($message->provider_id); + if (($email['id'] ?? null) !== $message->provider_id) { + throw new \RuntimeException('resend_status_identity_mismatch'); + } + $status = match ($email['last_event'] ?? '') { + 'sent' => 'submitted', 'delivery_delayed' => 'delayed', + 'delivered', 'opened', 'clicked' => 'delivered', + 'bounced' => 'bounced', 'failed' => 'failed', + 'complained' => 'complained', 'suppressed' => 'suppressed', + default => null, + }; + if (!$status) { return; } + DB::transaction(function () use ($message, $status) { + $current = MailMessage::whereKey($message->id)->lockForUpdate()->firstOrFail(); + if ($current->provider_id !== $message->provider_id) { return; } + if ($this->rank($status) >= $this->rank($current->status)) { + $current->status = $status; + $current->error_code = in_array($status, ['bounced', 'failed', 'suppressed', 'complained'], true) + ? 'resend_' . $status : null; + } + // GET gives the latest status, not its event time. Only webhooks fill + // provider_event_at/delivered_at; never fabricate a delivery timestamp. + $current->touch(); + }, 3); + } + + public function receive(string $id, array $event): void + { + if (!isset(self::STATUSES[$event['type'] ?? '']) || !is_string($event['data']['email_id'] ?? null)) { + return; + } + $record = MailMessage::firstOrCreate(['message_key' => 'event:' . hash('sha256', $id)], [ + 'direction' => 'event', 'purpose' => 'delivery_event', 'email' => '', 'subject' => '', + 'channel' => 'resend', 'provider_id' => $event['data']['email_id'], + 'payload' => ['type' => $event['type'], 'at' => $event['created_at'] ?? now()->toIso8601String()], + ]); + $this->apply($record); + } + + public function apply(MailMessage $record, bool $lookup = false): void + { + if ($record->status !== 'pending') { + return; + } + $message = MailMessage::where('direction', 'out')->where('channel', 'resend') + ->where('provider_id', $record->provider_id)->first(); + if (!$message && $lookup) { + // A webhook can precede the POST response, or recover a lost POST response. + $email = $this->client->retrieve($record->provider_id); + foreach ($email['tags'] ?? [] as $tag) { + if (($tag['name'] ?? '') === 'mail_key') { + $message = MailMessage::where('direction', 'out')->where('channel', 'resend') + ->where('message_key', $tag['value'])->first(); + break; + } + } + } + if (!$message) { + return; // Persisted until the outbound identifier is known. + } + DB::transaction(function () use ($record, $message) { + $record = MailMessage::whereKey($record->id)->lockForUpdate()->firstOrFail(); + if ($record->status !== 'pending') { + return; + } + $message = MailMessage::whereKey($message->id)->lockForUpdate()->firstOrFail(); + $event = $record->payload; + $at = Carbon::parse($event['at'])->setTimezone(config('app.timezone')); + $status = self::STATUSES[$event['type']]; + $this->quota->settle($message, $status !== 'failed' && $status !== 'suppressed'); + $message->provider_id = $record->provider_id; + if (!$message->provider_event_at || $at->gt($message->provider_event_at) + || ($at->eq($message->provider_event_at) && $this->rank($status) > $this->rank($message->status))) { + // Never downgrade a terminal complaint/bounce to a delayed/sent notification. + if ($this->rank($status) >= $this->rank($message->status)) { + $message->status = $status; + $message->provider_event_at = $at; + $message->error_code = in_array($status, ['bounced', 'failed', 'suppressed', 'complained'], true) ? 'resend_' . $status : null; + if ($status === 'delivered') { + $message->delivered_at = $at; + } + } + } + if (!in_array($status, ['failed', 'suppressed'], true)) { + $message->submitted_at ??= $at; + } + $message->payload = null; + $message->save(); + $record->update(['status' => 'processed', 'payload' => null]); + }, 3); + } + + private function rank(string $status): int + { + return match ($status) { + 'submitted' => 1, 'delayed' => 2, 'delivered' => 3, + 'failed', 'suppressed', 'bounced' => 4, 'complained' => 5, default => 0, + }; + } +} diff --git a/app/Services/MailFlow/Dispatcher.php b/app/Services/MailFlow/Dispatcher.php new file mode 100644 index 0000000..54a3db6 --- /dev/null +++ b/app/Services/MailFlow/Dispatcher.php @@ -0,0 +1,222 @@ + 'failed', 'error' => 'invalid_recipient', 'retryable' => false]; + } + $payload = ['to' => $params['email'], 'subject' => $subject, 'html' => $html, + 'headers' => $params['headers'] ?? []]; + if (!empty($params['attachments'])) { $payload['attachments'] = $params['attachments']; } + if ($params['purpose'] === 'service_expired') { + $payload['policy_context'] = ['user_id' => $params['user_id'] ?? null, + 'subscription_expired_at' => $params['subscription_expired_at'] ?? null]; + } + if (Settings::get('reply_to')) { $payload['reply_to'] = Settings::get('reply_to'); } + $persisted = $params['persisted_message'] ?? false; + if (!$persisted && !Settings::get('enabled') && isset($params['message_key']) + && in_array($params['purpose'], ['traffic_reminder', 'expiry_reminder', 'automatic_broadcast'], true) + && \Illuminate\Support\Facades\Schema::hasTable('v2_mail_messages')) { + $persisted = MailMessage::where('message_key', $params['message_key'])->exists(); + } + if (!Settings::get('enabled') && $params['purpose'] !== 'service_expired' && !$persisted) { + return AutomaticMailPolicy::decision($params['purpose'], $params) ?? $this->sendLegacy($payload); + } + $key = $params['message_key'] ?? (string) Str::uuid(); + $lock = Cache::lock('mailflow:message:' . $key, 60); + if (!$lock->get()) { + return ['status' => 'pending', 'error' => 'mail_busy', 'retryable' => true]; + } + try { + $message = MailMessage::firstOrCreate(['message_key' => $key], [ + 'purpose' => $params['purpose'] ?? 'other', 'email' => $params['email'], 'subject' => $subject, + 'template_name' => $params['template_name'] ?? null, 'user_id' => $params['user_id'] ?? null, + 'parent_id' => $params['parent_id'] ?? null, 'payload' => $payload, + 'expires_at' => $params['expires_at'] ?? null, + ]); + if (in_array($message->status, ['submitted', 'delivered', 'delayed', 'bounced', 'complained', 'suppressed', 'simulated', 'expired', 'failed', 'policy_blocked'], true)) { + return $this->result($message); + } + if ($message->status === 'sending') { + $message->update(['status' => 'unknown', 'error_code' => 'worker_interrupted']); + } + if ($message->status === 'pending' && isset($message->payload['email_params'])) { + $message->update(['payload' => $payload]); + } + if ($blocked = $this->checkPolicy($message)) { return $blocked; } + // SMTP has no cross-request idempotency. Never blindly repeat an uncertain SMTP submission. + if ($message->status === 'unknown' && $message->channel === 'conventional') { + return $this->result($message); + } + if ($message->expires_at?->isPast() && $message->status === 'unknown') { + $message->update(['payload' => null]); + return $this->result($message); + } + if ($message->expires_at?->isPast() && $message->status !== 'unknown') { + return $this->finish($message, 'expired', 'mail_expired', false); + } + if ($message->status === 'unknown' && $message->first_attempt_at?->lt(now()->subHours(23))) { + // Resend idempotency retention is 24h; stop before it expires. + $message->update(['payload' => null]); + return $this->result($message); + } + $message->increment('attempts'); + $payload = $message->payload; + if (!$payload) { + return $this->result($message); + } + $eligible = in_array($message->purpose, ['verification', 'login_link', 'ticket_reply'], true); + $useResend = $message->channel === 'resend' || ($eligible && Settings::get('resend_enabled') && Settings::get('resend_key') && Settings::get('resend_from')); + if ($useResend && !$message->reservation && $message->status !== 'unknown') { + try { + $useResend = $this->quota->reserve($message); + if (!$useResend) { + $message->fallback_reason = 'quota_exhausted'; + } + } catch (\Throwable $e) { + $useResend = false; + $message->fallback_reason = 'quota_unavailable'; + } + } + if ($useResend) { + return $this->sendResend($message, $payload); + } + return $this->sendConventional($message, $payload); + } finally { + $lock->release(); + } + } + + private function sendLegacy(array $payload): array + { + try { + return $this->conventional->send($payload) + ['error' => null, 'retryable' => false]; + } catch (\Throwable $e) { + if ($e instanceof \Symfony\Component\Mailer\Exception\UnexpectedResponseException && $e->getCode() >= 400 && $e->getCode() <= 599) { + return ['status' => 'failed', 'error' => 'smtp_rejected_' . $e->getCode(), 'retryable' => $e->getCode() < 500]; + } + // Even before enabling tracking, a transport exception is not proof of non-acceptance. + return ['status' => 'unknown', 'error' => 'conventional_transport_error', 'retryable' => false]; + } + } + + private function sendResend(MailMessage $message, array $payload): array + { + if ($blocked = $this->checkPolicy($message)) { return $blocked; } + if (empty($payload['from'])) { + $from = (string) Settings::get('resend_from'); + $payload['from'] = filter_var($from, FILTER_VALIDATE_EMAIL) + ? (new \Symfony\Component\Mime\Address($from, (string) admin_setting('app_name', config('mail.from.name'))))->toString() + : $from; + $payload['tags'] = [['name' => 'mail_key', 'value' => $message->message_key]]; + $message->payload = $payload; + } + $message->channel = 'resend'; + $message->status = 'sending'; + $message->first_attempt_at ??= now(); + $message->save(); + try { + $response = $this->resend->send($payload, 'dpl-mail/' . $message->message_key); + } catch (\Throwable $e) { + $message->update(['status' => 'unknown', 'error_code' => 'resend_transport_unknown']); + return $this->result($message, true); + } + if ($response->successful() && is_string($response->json('id'))) { + return DB::transaction(function () use ($message, $response) { + // A delivery webhook may have arrived before this response. + $current = MailMessage::whereKey($message->id)->lockForUpdate()->firstOrFail(); + $this->quota->settle($current, true); + $current->provider_id = $response->json('id'); + $current->submitted_at ??= now(); + if (in_array($current->status, ['sending', 'unknown', 'pending'], true)) { + $current->status = 'submitted'; + } + $current->payload = null; + $current->error_code = null; + $current->save(); + return $this->result($current); + }, 3); + } + $name = (string) $response->json('name', 'resend_rejected'); + if ($response->status() >= 500 || $response->status() === 408 || $response->status() === 409 || $response->successful()) { + $message->update(['status' => 'unknown', 'error_code' => 'resend_response_unknown']); + return $this->result($message, true); + } + // A definite HTTP rejection releases the reservation. Never route hard bounces through another sender. + DB::transaction(fn () => $this->quota->settle($message, false), 3); + if (in_array($name, ['daily_quota_exceeded', 'monthly_quota_exceeded'], true)) { + $this->quota->exhausted($name === 'daily_quota_exceeded' ? 'daily' : 'monthly'); + $message->fallback_reason = $name; + return $this->sendConventional($message, $payload); + } + if ($response->status() === 429) { + $message->update(['status' => 'pending', 'error_code' => 'resend_rate_limited']); + return $this->result($message, true); + } + return $this->finish($message, 'failed', 'resend_http_' . $response->status(), false); + } + + private function sendConventional(MailMessage $message, array $payload): array + { + if ($blocked = $this->checkPolicy($message)) { return $blocked; } + $message->channel = 'conventional'; + $message->status = 'sending'; + $message->first_attempt_at ??= now(); + $message->save(); + try { + $result = $this->conventional->send($payload); + } catch (\Throwable $e) { + if ($e instanceof \Symfony\Component\Mailer\Exception\UnexpectedResponseException && $e->getCode() >= 400 && $e->getCode() <= 599) { + $code = 'smtp_rejected_' . $e->getCode(); + if ($e->getCode() < 500) { + $message->update(['status' => 'pending', 'error_code' => $code]); + return $this->result($message, true); + } + return $this->finish($message, 'failed', $code, false); + } + $message->update(['status' => 'unknown', 'error_code' => 'conventional_transport_unknown']); + return $this->result($message); + } + $message->update(['status' => $result['status'], 'provider_id' => $result['provider_id'], + 'submitted_at' => $result['status'] === 'submitted' ? now() : null, 'error_code' => null, 'payload' => null]); + return $this->result($message); + } + + private function finish(MailMessage $message, string $status, string $error, bool $accepted): array + { + return DB::transaction(function () use ($message, $status, $error, $accepted) { + $this->quota->settle($message, $accepted); + $message->update(['status' => $status, 'error_code' => $error, 'payload' => null]); + return $this->result($message); + }, 3); + } + + private function checkPolicy(MailMessage $message): ?array + { + $context = $message->payload['policy_context'] ?? $message->payload['email_params'] ?? []; + $decision = AutomaticMailPolicy::decision($message->purpose, $context); + if (!$decision) { return null; } + // Unknown submissions retain their original status/quota until acceptance can be established. + if ($message->status === 'unknown') { return $this->result($message); } + if ($decision['retryable']) { return $decision + ['message_id' => $message->id]; } + return $this->finish($message, 'policy_blocked', $decision['error'], false); + } + + private function result(MailMessage $message, bool $retryable = false): array + { + return ['status' => $message->status, 'error' => $message->error_code, + 'retryable' => $retryable, 'message_id' => $message->id, 'channel' => $message->channel]; + } +} diff --git a/app/Services/MailFlow/ExpiredSubscriptionNotifier.php b/app/Services/MailFlow/ExpiredSubscriptionNotifier.php new file mode 100644 index 0000000..1180602 --- /dev/null +++ b/app/Services/MailFlow/ExpiredSubscriptionNotifier.php @@ -0,0 +1,46 @@ +where('banned', false)->whereNotNull('plan_id') + ->where('expired_at', '>', $state['since'])->where('expired_at', '<=', now()->timestamp) + ->chunkById(500, function ($users) use (&$queued) { + foreach ($users as $user) { + if (!filter_var($user->email, FILTER_VALIDATE_EMAIL)) { continue; } + $key = 'subscription-expired:' . $user->id . ':' . $user->expired_at; + $params = ['message_key' => $key, 'email' => $user->email, 'user_id' => $user->id, + 'purpose' => 'service_expired', 'subscription_expired_at' => (int) $user->expired_at, + 'template_name' => 'serviceExpired', 'subject' => admin_setting('app_name', 'XBoard') . ' - 套餐已到期', + 'template_value' => ['name' => admin_setting('app_name', 'XBoard'), 'url' => admin_setting('app_url')]]; + if (AutomaticMailPolicy::decision('service_expired', $params)) { continue; } + // Persist before dispatch. The same user can renew and receive a later distinct expiry event. + $message = MailMessage::firstOrCreate(['message_key' => $key], [ + 'purpose' => 'service_expired', 'user_id' => $user->id, 'email' => $user->email, + 'subject' => $params['subject'], 'template_name' => 'serviceExpired', + 'expires_at' => now()->addDay(), 'payload' => ['email_params' => $params], + ]); + if (!$message->wasRecentlyCreated && ($message->status !== 'pending' || !$message->payload + || $message->updated_at->gt(now()->subMinutes(2)))) { continue; } + if ($message->attempts >= 6) { + $message->update(['status' => 'failed', 'error_code' => 'mail_retry_exhausted', 'payload' => null]); + continue; + } + $message->touch(); + SendMailMessageJob::dispatch($message->id); + $queued++; + } + }); + return $queued; + } +} diff --git a/app/Services/MailFlow/ImapInbox.php b/app/Services/MailFlow/ImapInbox.php new file mode 100644 index 0000000..f4f67af --- /dev/null +++ b/app/Services/MailFlow/ImapInbox.php @@ -0,0 +1,115 @@ + 65535) { + throw new \RuntimeException('imap_configuration_invalid'); + } + foreach ([IMAP_OPENTIMEOUT, IMAP_READTIMEOUT, IMAP_WRITETIMEOUT, IMAP_CLOSETIMEOUT] as $timeout) { + imap_timeout($timeout, 8); + } + // TLS with certificate validation; never accept /novalidate-cert. + $mailbox = '{' . $host . ':' . $port . '/imap/ssl}' . $folder; + $connection = @imap_open($mailbox, (string) Settings::get('imap_username'), (string) Settings::get('imap_password'), OP_READONLY, 1); + if (!$connection) { + imap_errors(); + throw new \RuntimeException('imap_connection_failed'); + } + try { + $status = imap_status($connection, $mailbox, SA_UIDVALIDITY | SA_UIDNEXT); + if (!$status) { + throw new \RuntimeException('imap_status_failed'); + } + $identity = hash('sha256', $mailbox . ':' . Settings::get('imap_username')); + $cursorKey = 'mail_flow_imap_' . $identity; + $cursor = admin_setting($cursorKey); + if (!$cursor || ($cursor['validity'] ?? null) !== $status->uidvalidity) { + // First activation / UIDVALIDITY change: skip historical mail to avoid a reply flood. + admin_setting([$cursorKey => ['validity' => $status->uidvalidity, 'uid' => $status->uidnext - 1]]); + return 0; + } + $last = (int) $cursor['uid']; + if ($status->uidnext <= $last + 1) { + return 0; + } + // PHP's IMAP search parser does not support the UID search criterion. + // Fetch a bounded UID range instead, without downloading message bodies. + $upper = min($status->uidnext - 1, $last + 100); + $overview = imap_fetch_overview($connection, ($last + 1) . ':' . $upper, FT_UID); + if ($overview === false) { + throw new \RuntimeException('imap_overview_failed'); + } + $uids = array_map(static fn ($item) => (int) $item->uid, $overview); + sort($uids, SORT_NUMERIC); + $count = 0; + foreach (array_slice($uids, 0, 100) as $uid) { + if ($uid <= $last) { + continue; + } + $raw = imap_fetchheader($connection, $uid, FT_UID); + if ($raw === false) { + throw new \RuntimeException('imap_header_failed'); + } + $parsed = imap_rfc822_parse_headers($raw); + $from = $parsed->from[0] ?? null; + $sender = $from && isset($from->mailbox, $from->host) ? $from->mailbox . '@' . $from->host : ''; + $headers = $this->headers($raw); + $structure = imap_fetchstructure($connection, $uid, FT_UID); + $content = $structure ? $this->content->read($connection, $uid, $structure) : ['text' => '', 'dsn' => '']; + $this->content->recordBounce($content['dsn'], $headers); + $subject = isset($parsed->subject) ? mb_decode_mimeheader($parsed->subject) : ''; + $message = DB::transaction(function () use ($identity, $status, $uid, $sender, $subject, $headers, $content) { + $message = MailMessage::firstOrCreate(['message_key' => 'imap:' . hash('sha256', $identity . ':' . $status->uidvalidity . ':' . $uid)], [ + 'direction' => 'in', 'purpose' => 'incoming', 'channel' => 'conventional', + 'email' => mb_substr($sender, 0, 254), 'subject' => mb_substr($subject, 0, 998), + 'provider_id' => isset($headers['message-id']) ? mb_substr($headers['message-id'], 0, 250) : null, 'status' => 'received', + 'payload' => ['text' => $content['text']], + ]); + $reply = $content['dsn'] === '' ? $this->replies->prepare($message, $headers) : null; + return $reply; + }, 3); + // The durable outgoing row is recoverable even if queue dispatch fails. + if ($message) { + SendMailMessageJob::dispatch($message->id); + } + admin_setting([$cursorKey => ['validity' => $status->uidvalidity, 'uid' => $uid]]); + $count++; + } + // Deleted UID gaps must not stall subsequent synchronization. + admin_setting([$cursorKey => ['validity' => $status->uidvalidity, 'uid' => $upper]]); + return $count; + } finally { + imap_close($connection); + imap_errors(); + } + } + + private function headers(string $raw): array + { + $result = []; + $raw = preg_replace('/\r?\n[ \t]+/', ' ', $raw); + foreach (preg_split('/\r?\n/', $raw) as $line) { + if (str_contains($line, ':')) { + [$key, $value] = explode(':', $line, 2); + $result[strtolower(trim($key))] = trim($value); + } + } + return $result; + } +} diff --git a/app/Services/MailFlow/IncomingContent.php b/app/Services/MailFlow/IncomingContent.php new file mode 100644 index 0000000..1733260 --- /dev/null +++ b/app/Services/MailFlow/IncomingContent.php @@ -0,0 +1,63 @@ +parts($structure) as [$part, $number]) { + $subtype = strtolower($part->subtype ?? ''); + if (!in_array($subtype, ['plain', 'html', 'delivery-status'], true) || ($part->bytes ?? 0) > 262144) { + continue; + } + $raw = imap_fetchbody($connection, $uid, $number, FT_UID | FT_PEEK); + if ($raw === false || strlen($raw) > 262144) { continue; } + $decoded = $this->decode($raw, (int) ($part->encoding ?? 0), $part->parameters ?? []); + if ($subtype === 'delivery-status') { $dsn .= $decoded; } + elseif ($subtype === 'html' && $htmlText === '') { + $htmlText = html_entity_decode(strip_tags(preg_replace('/<(script|style)\b[^>]*>.*?<\/\1>/is', '', $decoded)), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } elseif ($text === '') { $text = $decoded; } + } + return ['text' => mb_substr($text ?: $htmlText, 0, 65536), 'dsn' => $dsn]; + } + + private function parts(object $part, string $number = ''): array + { + if (!empty($part->parts)) { + $parts = []; + foreach ($part->parts as $index => $child) { + $parts = array_merge($parts, $this->parts($child, $number === '' ? (string) ($index + 1) : $number . '.' . ($index + 1))); + } + return $parts; + } + return [[$part, $number ?: '1']]; + } + + public function decode(string $raw, int $encoding, array $parameters): string + { + $text = match ($encoding) { 3 => base64_decode($raw, true) ?: '', 4 => quoted_printable_decode($raw), default => $raw }; + foreach ($parameters as $parameter) { + if (strtolower($parameter->attribute ?? '') === 'charset') { + try { $text = mb_convert_encoding($text, 'UTF-8', $parameter->value); } catch (\ValueError $e) { /* keep original */ } + } + } + return mb_convert_encoding($text, 'UTF-8', 'UTF-8'); + } + + public function recordBounce(string $dsn, array $headers): void + { + if (!preg_match('/^Action:\s*failed\s*$/mi', $dsn)) { return; } + preg_match('/^Original-Message-ID:\s*([^<>\r\n]+)>?\s*$/mi', $dsn, $id); + $messageId = trim($id[1] ?? ($headers['in-reply-to'] ?? ''), '<> '); + preg_match('/^Final-Recipient:\s*rfc822;\s*([^\s\r\n]+)/mi', $dsn, $recipient); + if (!$messageId || !isset($recipient[1])) { return; } + // Associate a DSN with both the original message ID and recipient, never by subject. + MailMessage::where('direction', 'out')->where('channel', 'conventional') + ->where('provider_id', $messageId)->where('email', $recipient[1]) + ->whereIn('status', ['submitted', 'unknown'])->update(['status' => 'bounced', 'error_code' => 'conventional_permanent_bounce']); + } +} diff --git a/app/Services/MailFlow/QuotaService.php b/app/Services/MailFlow/QuotaService.php new file mode 100644 index 0000000..0f052c3 --- /dev/null +++ b/app/Services/MailFlow/QuotaService.php @@ -0,0 +1,84 @@ +reservation) { + return true; + } + // One account on this website. Changing the API key does not grant extra quota. + $usage = Cache::remember('mailflow:resend:usage', 15, fn () => $this->client->usage()); + foreach (['daily', 'monthly'] as $period) { + if (!isset($usage[$period]['used'], $usage[$period]['resets_at']) || !is_numeric($usage[$period]['used'])) { + throw new \RuntimeException('resend_usage_invalid'); + } + } + return DB::transaction(function () use ($usage, $message) { + $buckets = []; + foreach (['daily', 'monthly'] as $period) { + $reset = Carbon::parse($usage[$period]['resets_at'])->setTimezone(config('app.timezone')); + if ($reset->isPast()) { + Cache::forget('mailflow:resend:usage'); + throw new \RuntimeException('resend_usage_stale'); + } + $limit = min((int) Settings::get($period . '_limit'), (int) ($usage[$period]['limit'] ?? Settings::get($period . '_limit'))); + $key = $period . ':' . $reset->timestamp; + DB::table('v2_mail_quota_buckets')->insertOrIgnore([ + 'period_key' => $key, 'period' => $period, 'resets_at' => $reset, + 'limit' => $limit, 'used' => 0, 'reserved' => 0, + 'created_at' => now(), 'updated_at' => now(), + ]); + $bucket = MailQuotaBucket::where('period_key', $key)->lockForUpdate()->firstOrFail(); + $bucket->used = max($bucket->used, (int) $usage[$period]['used']); + $bucket->limit = $limit; + $bucket->synced_at = now(); + $bucket->save(); + $buckets[] = $bucket; + } + foreach ($buckets as $bucket) { + if ($bucket->used + $bucket->reserved >= $bucket->limit) { + return false; + } + } + foreach ($buckets as $bucket) { + $bucket->increment('reserved'); + } + $message->reservation = array_map(fn ($bucket) => $bucket->id, $buckets); + $message->save(); + return true; + }, 3); + } + + // Call inside the same transaction that records the message result. + public function settle(MailMessage $message, bool $accepted): void + { + foreach ($message->reservation ?? [] as $id) { + $bucket = MailQuotaBucket::whereKey($id)->lockForUpdate()->firstOrFail(); + $bucket->reserved = max(0, $bucket->reserved - 1); + if ($accepted) { + $bucket->used++; + } + $bucket->save(); + } + $message->reservation = null; + $message->save(); + } + + public function exhausted(string $period): void + { + Cache::forget('mailflow:resend:usage'); + MailQuotaBucket::where('period', $period)->where('resets_at', '>', now()) + ->update(['used' => DB::raw('`limit`')]); + } +} diff --git a/app/Services/MailFlow/ResendClient.php b/app/Services/MailFlow/ResendClient.php new file mode 100644 index 0000000..bbcedb2 --- /dev/null +++ b/app/Services/MailFlow/ResendClient.php @@ -0,0 +1,40 @@ +withToken((string) Settings::get('resend_key')) + ->acceptJson()->connectTimeout(3)->timeout(8); + } + + public function usage(): array + { + $response = $this->request()->get('/usage'); + if (!$response->successful()) { + throw new \RuntimeException('resend_usage_unavailable'); + } + return $response->json('emails') ?? []; + } + + public function send(array $payload, string $key): Response + { + // Automatic HTTP retries would hide uncertainty; retries use the same persisted key/payload. + return $this->request()->withHeaders(['Idempotency-Key' => $key])->post('/emails', $payload); + } + + public function retrieve(string $id): array + { + $response = $this->request()->get('/emails/' . rawurlencode($id)); + if (!$response->successful()) { + throw new \RuntimeException('resend_status_unavailable'); + } + return $response->json(); + } +} diff --git a/app/Services/MailFlow/Settings.php b/app/Services/MailFlow/Settings.php new file mode 100644 index 0000000..4228696 --- /dev/null +++ b/app/Services/MailFlow/Settings.php @@ -0,0 +1,32 @@ + 300 || !str_starts_with($secret, 'whsec_')) { + return false; + } + $key = base64_decode(substr($secret, 6), true); + if ($key === false || strlen($key) < 16) { + return false; + } + $expected = base64_encode(hash_hmac('sha256', $id . '.' . $timestamp . '.' . $body, $key, true)); + foreach (preg_split('/\s+/', trim($signatures)) as $signature) { + if (str_starts_with($signature, 'v1,') && hash_equals($expected, substr($signature, 3))) { + return true; + } + } + return false; + } +} diff --git a/app/Services/MailService.php b/app/Services/MailService.php index 9bc8474..26bb9c4 100644 --- a/app/Services/MailService.php +++ b/app/Services/MailService.php @@ -8,9 +8,7 @@ use App\Models\MailTemplate; use App\Models\User; use App\Utils\CacheKey; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Mail; class MailService { @@ -165,6 +163,7 @@ class MailService public function remindTraffic(User $user) { + if (\App\Services\MailFlow\AutomaticMailPolicy::decision('traffic_reminder')) { return; } if (!$user->remind_traffic) return; if (!$this->remindTrafficIsWarnValue($user->u, $user->d, $user->transfer_enable)) @@ -181,6 +180,8 @@ class MailService 'app_name' => admin_setting('app_name', 'XBoard') ]), 'template_name' => 'remindTraffic', + 'purpose' => 'traffic_reminder', + 'user_id' => $user->id, 'template_value' => [ 'name' => admin_setting('app_name', 'XBoard'), 'url' => admin_setting('app_url') @@ -190,6 +191,7 @@ class MailService public function remindExpire(User $user) { + if (\App\Services\MailFlow\AutomaticMailPolicy::decision('expiry_reminder')) { return; } if (!$this->shouldSendExpireRemind($user)) { return; } @@ -200,6 +202,8 @@ class MailService 'app_name' => admin_setting('app_name', 'XBoard') ]), 'template_name' => 'remindExpire', + 'purpose' => 'expiry_reminder', + 'user_id' => $user->id, 'template_value' => [ 'name' => admin_setting('app_name', 'XBoard'), 'url' => admin_setting('app_url') @@ -239,20 +243,16 @@ class MailService */ public static function sendEmail(array $params) { - 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'))); - Config::set('mail.encryption', admin_setting('email_encryption', config('mail.encryption'))); - 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')); - } + $params['purpose'] = \App\Services\MailFlow\AutomaticMailPolicy::purpose($params); $email = $params['email']; $subject = $params['subject']; $templateName = $params['template_name']; $templateValue = $params['template_value'] ?? []; + // Website branding is independent of the mailbox domain and action links. + if (is_array($templateValue)) { + $templateValue = array_replace($templateValue, \App\Services\MailFlow\Branding::values()); + } $vars = is_array($templateValue) ? ($templateValue['vars'] ?? []) : []; $contentMode = is_array($templateValue) ? ($templateValue['content_mode'] ?? null) : null; @@ -288,34 +288,34 @@ class MailService $renderedContent = self::renderPlaceholders($dbTemplate->content, $renderVars); $subject = $renderedSubject ?: $subject; - Mail::html($renderedContent, function ($message) use ($email, $subject) { - $message->to($email)->subject($subject); - }); + $html = $renderedContent; $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); - } - ); + $html = view($params['template_name'], $params['template_value'])->render(); } - $error = null; - } catch (\Exception $e) { - Log::error($e); - $error = $e->getMessage(); + if (str_contains($html, 'cid:site-logo@dpl')) { + $params['attachments'] = \App\Services\MailFlow\Branding::attachments(); + } + $result = app(\App\Services\MailFlow\Dispatcher::class)->send($params, $subject, $html); + } catch (\Throwable $e) { + // No provider response, template values or credentials in application logs. + $result = ['status' => 'failed', 'error' => 'mail_processing_error', 'retryable' => false]; + Log::warning('Mail processing failed', ['exception_type' => get_class($e)]); } $log = [ 'email' => $params['email'], 'subject' => $params['subject'], 'template_name' => $params['template_name'], - 'error' => $error, - 'config' => config('mail') + 'error' => $result['error'] ?? null ]; - MailLog::create($log); - return $log; + try { + MailLog::create($log); + } catch (\Throwable $e) { + // A log failure after submission must not trigger another email. + Log::warning('Mail log persistence failed', ['exception_type' => get_class($e)]); + } + return $log + $result; } /** diff --git a/app/Services/TicketService.php b/app/Services/TicketService.php index 84ee389..5521f5f 100644 --- a/app/Services/TicketService.php +++ b/app/Services/TicketService.php @@ -49,7 +49,11 @@ class TicketService throw new ApiException('工单回复失败'); } HookManager::call('ticket.reply.admin.after', [$ticket, $ticketMessage]); - $this->sendEmailNotify($ticket, $ticketMessage); + try { + $this->sendEmailNotify($ticket, $ticketMessage); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::warning('Ticket email notification could not be queued', ['ticket_id' => $ticket->id]); + } } public function createTicket($userId, $subject, $level, $message) @@ -91,6 +95,9 @@ class TicketService private function sendEmailNotify(Ticket $ticket, TicketMessage $ticketMessage) { $user = User::find($ticket->user_id); + if (!$user || !filter_var($user->email, FILTER_VALIDATE_EMAIL)) { + return; + } $cacheKey = 'ticket_sendEmailNotify_' . $ticket->user_id; if (!Cache::get($cacheKey)) { Cache::put($cacheKey, 1, 1800); @@ -98,6 +105,8 @@ class TicketService 'email' => $user->email, 'subject' => '您在' . admin_setting('app_name', 'XBoard') . '的工单得到了回复', 'template_name' => 'notify', + 'purpose' => 'ticket_reply', + 'user_id' => $user->id, 'template_value' => [ 'name' => admin_setting('app_name', 'XBoard'), 'url' => admin_setting('app_url'), diff --git a/config/mailflow.php b/config/mailflow.php new file mode 100644 index 0000000..e0c4753 --- /dev/null +++ b/config/mailflow.php @@ -0,0 +1,26 @@ + env('MAIL_FLOW_ENABLED', false), + 'resend_enabled' => env('RESEND_ENABLED', false), + 'resend_key' => env('RESEND_API_KEY'), + 'resend_from' => env('RESEND_FROM_ADDRESS'), + 'resend_webhook_secret' => env('RESEND_WEBHOOK_SECRET'), + 'reply_to' => env('MAIL_REPLY_TO'), + 'logo_path' => env('MAIL_LOGO_PATH', public_path('images/mail-logo.png')), + 'daily_limit' => 100, + 'monthly_limit' => 3000, + 'inbound_enabled' => env('MAIL_INBOUND_ENABLED', false), + 'imap_host' => env('MAIL_IMAP_HOST'), + 'imap_port' => (int) env('MAIL_IMAP_PORT', 993), + 'imap_username' => env('MAIL_IMAP_USERNAME'), + 'imap_password' => env('MAIL_IMAP_PASSWORD'), + 'imap_folder' => 'INBOX', + 'auto_reply_enabled' => false, + 'auto_reply_subject' => '我们已收到您的邮件', + 'auto_reply_text' => '您的邮件已收到,我们会尽快处理。', + 'report_enabled' => env('MAIL_REPORT_ENABLED', false), + 'master_url' => env('MAIL_MASTER_URL'), + 'master_bot_code' => env('MAIL_MASTER_BOT_CODE'), + 'master_chat_code' => env('MAIL_MASTER_CHAT_CODE'), +]; diff --git a/database/migrations/2026_10_09_170000_create_mail_flow_tables.php b/database/migrations/2026_10_09_170000_create_mail_flow_tables.php new file mode 100644 index 0000000..28fe485 --- /dev/null +++ b/database/migrations/2026_10_09_170000_create_mail_flow_tables.php @@ -0,0 +1,65 @@ +id(); + $table->string('message_key', 100)->unique(); + $table->string('direction', 8)->default('out'); + $table->string('purpose', 32); + $table->unsignedBigInteger('user_id')->nullable()->index(); + $table->unsignedBigInteger('parent_id')->nullable()->unique(); + $table->string('email', 254); + $table->string('subject', 998); + $table->string('template_name')->nullable(); + $table->string('channel', 24)->nullable(); + $table->string('provider_id')->nullable()->index(); + $table->string('status', 24)->default('pending')->index(); + $table->text('payload')->nullable(); // encrypted; cleared after completion/expiry + $table->json('reservation')->nullable(); + $table->unsignedInteger('attempts')->default(0); + $table->string('error_code', 100)->nullable(); + $table->string('fallback_reason', 100)->nullable(); + $table->timestamp('first_attempt_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('submitted_at')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamp('provider_event_at')->nullable(); + $table->timestamps(); + $table->index(['direction', 'created_at']); + }); + Schema::create('v2_mail_quota_buckets', function (Blueprint $table) { + $table->id(); + $table->string('period_key', 100)->unique(); + $table->string('period', 8); + $table->timestamp('resets_at'); + $table->unsignedInteger('limit'); + $table->unsignedInteger('used')->default(0); + $table->unsignedInteger('reserved')->default(0); + $table->timestamp('synced_at')->nullable(); + $table->timestamps(); + }); + Schema::create('v2_mail_daily_reports', function (Blueprint $table) { + $table->id(); + $table->date('report_date')->unique(); + $table->json('summary'); + $table->string('status', 24)->default('pending'); + $table->unsignedInteger('attempts')->default(0); + $table->timestamp('submitted_at')->nullable(); + $table->string('error_code', 100)->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('v2_mail_daily_reports'); + Schema::dropIfExists('v2_mail_quota_buckets'); + Schema::dropIfExists('v2_mail_messages'); + } +}; diff --git a/public/admin-custom/mail-flow.js b/public/admin-custom/mail-flow.js new file mode 100644 index 0000000..a1473a0 --- /dev/null +++ b/public/admin-custom/mail-flow.js @@ -0,0 +1,80 @@ +// Standalone settings panel, using the existing authenticated admin API. +const host = document.createElement('div'); +host.id = 'mail-flow-tools'; +const style = document.createElement('style'); +style.textContent = `#mail-flow-tools .entry{position:fixed;right:24px;bottom:72px;z-index:40;padding:9px 14px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;color:#0f172a;cursor:pointer}#mail-flow-dialog{width:min(760px,calc(100vw - 40px));max-height:85vh;overflow:auto;border:1px solid #cbd5e1;border-radius:12px;padding:24px;background:#fff;color:#0f172a}#mail-flow-dialog::backdrop{background:#0007}#mail-flow-dialog fieldset{border:1px solid #e2e8f0;border-radius:8px;margin:14px 0;padding:12px}#mail-flow-dialog label{display:block;margin:10px 0;font-size:14px}#mail-flow-dialog select,#mail-flow-dialog input:not([type=checkbox]),#mail-flow-dialog textarea{display:block;width:100%;padding:7px;border:1px solid #cbd5e1;border-radius:5px;box-sizing:border-box}#mail-flow-dialog button{padding:8px 14px;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer}#mail-flow-dialog pre{white-space:pre-wrap;font-size:12px}#mail-flow-dialog p{font-size:13px;line-height:1.6}`; +host.append(style); +const entry = document.createElement('button'); entry.className='entry'; entry.textContent='邮件渠道与自动回复'; entry.type='button'; entry.hidden=true; host.append(entry); +const dialog=document.createElement('dialog'); dialog.id='mail-flow-dialog'; host.append(dialog); document.body.append(host); +const visibility=()=>{entry.hidden=!location.hash.startsWith('#/config') || !document.querySelector('#root h2'); if(entry.hidden && dialog.open) dialog.close();}; +window.addEventListener('hashchange',visibility); +new MutationObserver(visibility).observe(document.getElementById('root'),{childList:true,subtree:true}); visibility(); +async function api(action, payload) { + let token; try { token=JSON.parse(localStorage.getItem('XBOARD_ACCESS_TOKEN')||'null'); } catch { throw new Error('请重新登录后台'); } + if(!token?.value || (token.expire!=null && token.expire<=Date.now())) throw new Error('请重新登录后台'); + const base=new URL(window.settings?.base_url||'/',location.origin); + if(base.origin!==location.origin) throw new Error('后台接口必须同源'); + const url=base.pathname.replace(/\/$/,'')+'/api/v2/'+encodeURIComponent(window.settings.secure_path)+'/config/'+action; + const response=await fetch(url,{method:payload?'POST':'GET',credentials:'same-origin',headers:{Authorization:token.value,'Content-Type':'application/json','Content-Language':'zh-CN'},...(payload?{body:JSON.stringify(payload)}:{})}); + const data=await response.json(); + if(!response.ok || data.status==='fail' || data.data===false) throw new Error(data.message||'请求失败,请检查输入'); + return data.data; +} +const groups=[ + ['发送渠道', [['enabled','启用邮件记录与分流','checkbox'],['resend_enabled','验证码、登录链接和工单通知优先 Resend','checkbox'],['resend_key','Resend API 密钥','password'],['resend_from','Resend 发件地址','email'],['resend_webhook_secret','Resend 回调签名密钥','password'],['reply_to','统一回复地址(常规邮箱)','email'],['daily_limit','日额度上限','number'],['monthly_limit','月额度上限','number']]], + ['常规邮箱收件与自动回复', [['inbound_enabled','启用收件','checkbox'],['imap_host','IMAP 主机','text'],['imap_port','IMAP TLS 端口','number'],['imap_username','IMAP 用户名','text'],['imap_password','IMAP 密码','password'],['imap_folder','收件文件夹','text'],['auto_reply_enabled','自动确认收件(按发件地址限频)','checkbox'],['auto_reply_subject','自动回复标题','text'],['auto_reply_text','自动回复内容','textarea']]], + ['每天八点汇报', [['report_enabled','启用 master 汇报','checkbox'],['master_url','Master 基础地址(HTTPS)','url'],['master_bot_code','Master 机器人代号','text'],['master_chat_code','Master 接收会话代号','text']]], +]; +entry.addEventListener('click',async()=>{ + dialog.textContent='正在读取…'; dialog.showModal(); entry.disabled=true; + try { + const data=await api('mailFlowStatus'); dialog.replaceChildren(); + const heading=document.createElement('h2'); heading.textContent='邮件渠道与自动回复'; dialog.append(heading); + const note=document.createElement('p'); note.textContent='群发、流量和到期提醒仍用原常规邮箱。Resend 额度不足时回退;收件固定用常规邮箱。密钥留空保留。自动回复仅处理启用之后的新邮件,并限制每个发件地址北京时间每天一次,无需绑定账户。'; dialog.append(note); + const readiness=document.createElement('p'); readiness.textContent=(data.database_ready?'数据库迁移已就绪。':'数据库尚未迁移,暂不可启用。')+(data.imap_available?' IMAP 扩展可用。':' 收件需要安装 PHP IMAP 扩展。'); dialog.append(readiness); + const form=document.createElement('form'); dialog.append(form); const inputs={}; + const tierLabel=document.createElement('label'); tierLabel.textContent='自动邮件档位'; + const tier=document.createElement('select'); tier.name='automatic_mail_level'; + for(const [value,label] of [[1,'一档:保持原有提醒'],[2,'二档:仅套餐到期时提醒'],[3,'三档:关闭自动批量提醒']]) { + const option=document.createElement('option'); option.value=String(value); option.textContent=label; tier.append(option); + } + if(data.automatic_mail_level==null) throw new Error('自动邮件档位暂不可读取,请稍后重试'); + tier.value=String(data.automatic_mail_level); tierLabel.append(tier); form.append(tierLabel); + const tierNote=document.createElement('p'); tierNote.textContent='人工群发、验证码、登录、工单回复和收件自动回复均不受档位影响。二档保留原提醒开关与用户到期提醒偏好,从启用二档之后的新到期开始,每分钟检查,不补发此前历史记录;改档取消禁发任务,恢复档位不补发已取消邮件。'; form.append(tierNote); + for(const [title, fields] of groups){ + const fieldset=document.createElement('fieldset'); const legend=document.createElement('legend'); legend.textContent=title; fieldset.append(legend); + for(const [key,label,type] of fields){ + const wrap=document.createElement('label'); wrap.textContent=label; + const input=document.createElement(type==='textarea'?'textarea':'input'); + if(type!=='textarea') input.type=type; + if(type==='checkbox') input.checked=!!data.settings[key]; + else if(type==='password') {input.autocomplete='new-password'; input.placeholder=data.settings[key+'_configured']?'已配置,留空保留':'尚未配置';} + else input.value=data.settings[key]??''; + if(type==='number'){input.step='1';input.min='1';if(key==='daily_limit') input.max='100';if(key==='monthly_limit') input.max='3000';if(key==='imap_port') input.max='65535';} + inputs[key]=input; wrap.append(input); fieldset.append(wrap); + } form.append(fieldset); + } + const message=document.createElement('p'); message.setAttribute('role','status'); form.append(message); + const save=document.createElement('button'); save.type='submit'; save.textContent='保存'; form.append(save); + const close=document.createElement('button'); close.type='button'; close.textContent='关闭';close.addEventListener('click',()=>dialog.close());form.append(close); + const history=document.createElement('pre');history.textContent='最近邮件:\n'+data.messages.map(m=>`${m.created_at} · ${m.purpose} · ${m.channel||'待选择'} · ${m.status}${m.fallback_reason?' · '+m.fallback_reason:''}`).join('\n');dialog.append(history); + for(const incoming of data.messages.filter(m=>m.direction==='in')) { + const view=document.createElement('button'); view.type='button'; view.textContent='查看来信:'+incoming.email; + const content=document.createElement('pre'); dialog.append(view,content); + view.addEventListener('click',async()=>{view.disabled=true;try{const mail=await api('mailFlowIncoming?id='+encodeURIComponent(incoming.id));content.textContent=mail.subject+'\n\n'+mail.text;}catch(error){content.textContent=error.message;}finally{view.disabled=false;}}); + } + const reports=document.createElement('pre'); reports.textContent='最近汇报:\n'+data.reports.map(r=>r.report_date+' · '+r.status).join('\n');dialog.append(reports); + form.addEventListener('submit',async event=>{ + event.preventDefault();if(!form.reportValidity()) return; + if((inputs.enabled.checked || Number(tier.value)===2) && !data.database_ready){message.textContent='请先执行数据库迁移。';return;} + if(inputs.inbound_enabled.checked && !data.imap_available){message.textContent='请先安装 PHP IMAP 扩展。';return;} + const values={};for(const [key,input] of Object.entries(inputs)){ + if(input.type==='checkbox') values[key]=input.checked; + else if(input.type==='password' && !input.value) continue; + else values[key]=input.type==='number'?Number(input.value):input.value; + } + save.disabled=true;try{await api('save',{mail_flow:values,automatic_mail_level:Number(tier.value)});message.textContent='配置已保存。实际收发结果请查看邮件状态。';}catch(error){message.textContent=error.message;}finally{save.disabled=false;} + }); + } catch(error) {dialog.textContent=error.message;const close=document.createElement('button');close.textContent='关闭';close.addEventListener('click',()=>dialog.close());dialog.append(close);} + finally {entry.disabled=false;} +}); diff --git a/public/images/mail-logo.png b/public/images/mail-logo.png new file mode 100644 index 0000000..87c844f Binary files /dev/null and b/public/images/mail-logo.png differ diff --git a/resources/js/admin/mail-flow.js b/resources/js/admin/mail-flow.js new file mode 100644 index 0000000..a1473a0 --- /dev/null +++ b/resources/js/admin/mail-flow.js @@ -0,0 +1,80 @@ +// Standalone settings panel, using the existing authenticated admin API. +const host = document.createElement('div'); +host.id = 'mail-flow-tools'; +const style = document.createElement('style'); +style.textContent = `#mail-flow-tools .entry{position:fixed;right:24px;bottom:72px;z-index:40;padding:9px 14px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;color:#0f172a;cursor:pointer}#mail-flow-dialog{width:min(760px,calc(100vw - 40px));max-height:85vh;overflow:auto;border:1px solid #cbd5e1;border-radius:12px;padding:24px;background:#fff;color:#0f172a}#mail-flow-dialog::backdrop{background:#0007}#mail-flow-dialog fieldset{border:1px solid #e2e8f0;border-radius:8px;margin:14px 0;padding:12px}#mail-flow-dialog label{display:block;margin:10px 0;font-size:14px}#mail-flow-dialog select,#mail-flow-dialog input:not([type=checkbox]),#mail-flow-dialog textarea{display:block;width:100%;padding:7px;border:1px solid #cbd5e1;border-radius:5px;box-sizing:border-box}#mail-flow-dialog button{padding:8px 14px;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer}#mail-flow-dialog pre{white-space:pre-wrap;font-size:12px}#mail-flow-dialog p{font-size:13px;line-height:1.6}`; +host.append(style); +const entry = document.createElement('button'); entry.className='entry'; entry.textContent='邮件渠道与自动回复'; entry.type='button'; entry.hidden=true; host.append(entry); +const dialog=document.createElement('dialog'); dialog.id='mail-flow-dialog'; host.append(dialog); document.body.append(host); +const visibility=()=>{entry.hidden=!location.hash.startsWith('#/config') || !document.querySelector('#root h2'); if(entry.hidden && dialog.open) dialog.close();}; +window.addEventListener('hashchange',visibility); +new MutationObserver(visibility).observe(document.getElementById('root'),{childList:true,subtree:true}); visibility(); +async function api(action, payload) { + let token; try { token=JSON.parse(localStorage.getItem('XBOARD_ACCESS_TOKEN')||'null'); } catch { throw new Error('请重新登录后台'); } + if(!token?.value || (token.expire!=null && token.expire<=Date.now())) throw new Error('请重新登录后台'); + const base=new URL(window.settings?.base_url||'/',location.origin); + if(base.origin!==location.origin) throw new Error('后台接口必须同源'); + const url=base.pathname.replace(/\/$/,'')+'/api/v2/'+encodeURIComponent(window.settings.secure_path)+'/config/'+action; + const response=await fetch(url,{method:payload?'POST':'GET',credentials:'same-origin',headers:{Authorization:token.value,'Content-Type':'application/json','Content-Language':'zh-CN'},...(payload?{body:JSON.stringify(payload)}:{})}); + const data=await response.json(); + if(!response.ok || data.status==='fail' || data.data===false) throw new Error(data.message||'请求失败,请检查输入'); + return data.data; +} +const groups=[ + ['发送渠道', [['enabled','启用邮件记录与分流','checkbox'],['resend_enabled','验证码、登录链接和工单通知优先 Resend','checkbox'],['resend_key','Resend API 密钥','password'],['resend_from','Resend 发件地址','email'],['resend_webhook_secret','Resend 回调签名密钥','password'],['reply_to','统一回复地址(常规邮箱)','email'],['daily_limit','日额度上限','number'],['monthly_limit','月额度上限','number']]], + ['常规邮箱收件与自动回复', [['inbound_enabled','启用收件','checkbox'],['imap_host','IMAP 主机','text'],['imap_port','IMAP TLS 端口','number'],['imap_username','IMAP 用户名','text'],['imap_password','IMAP 密码','password'],['imap_folder','收件文件夹','text'],['auto_reply_enabled','自动确认收件(按发件地址限频)','checkbox'],['auto_reply_subject','自动回复标题','text'],['auto_reply_text','自动回复内容','textarea']]], + ['每天八点汇报', [['report_enabled','启用 master 汇报','checkbox'],['master_url','Master 基础地址(HTTPS)','url'],['master_bot_code','Master 机器人代号','text'],['master_chat_code','Master 接收会话代号','text']]], +]; +entry.addEventListener('click',async()=>{ + dialog.textContent='正在读取…'; dialog.showModal(); entry.disabled=true; + try { + const data=await api('mailFlowStatus'); dialog.replaceChildren(); + const heading=document.createElement('h2'); heading.textContent='邮件渠道与自动回复'; dialog.append(heading); + const note=document.createElement('p'); note.textContent='群发、流量和到期提醒仍用原常规邮箱。Resend 额度不足时回退;收件固定用常规邮箱。密钥留空保留。自动回复仅处理启用之后的新邮件,并限制每个发件地址北京时间每天一次,无需绑定账户。'; dialog.append(note); + const readiness=document.createElement('p'); readiness.textContent=(data.database_ready?'数据库迁移已就绪。':'数据库尚未迁移,暂不可启用。')+(data.imap_available?' IMAP 扩展可用。':' 收件需要安装 PHP IMAP 扩展。'); dialog.append(readiness); + const form=document.createElement('form'); dialog.append(form); const inputs={}; + const tierLabel=document.createElement('label'); tierLabel.textContent='自动邮件档位'; + const tier=document.createElement('select'); tier.name='automatic_mail_level'; + for(const [value,label] of [[1,'一档:保持原有提醒'],[2,'二档:仅套餐到期时提醒'],[3,'三档:关闭自动批量提醒']]) { + const option=document.createElement('option'); option.value=String(value); option.textContent=label; tier.append(option); + } + if(data.automatic_mail_level==null) throw new Error('自动邮件档位暂不可读取,请稍后重试'); + tier.value=String(data.automatic_mail_level); tierLabel.append(tier); form.append(tierLabel); + const tierNote=document.createElement('p'); tierNote.textContent='人工群发、验证码、登录、工单回复和收件自动回复均不受档位影响。二档保留原提醒开关与用户到期提醒偏好,从启用二档之后的新到期开始,每分钟检查,不补发此前历史记录;改档取消禁发任务,恢复档位不补发已取消邮件。'; form.append(tierNote); + for(const [title, fields] of groups){ + const fieldset=document.createElement('fieldset'); const legend=document.createElement('legend'); legend.textContent=title; fieldset.append(legend); + for(const [key,label,type] of fields){ + const wrap=document.createElement('label'); wrap.textContent=label; + const input=document.createElement(type==='textarea'?'textarea':'input'); + if(type!=='textarea') input.type=type; + if(type==='checkbox') input.checked=!!data.settings[key]; + else if(type==='password') {input.autocomplete='new-password'; input.placeholder=data.settings[key+'_configured']?'已配置,留空保留':'尚未配置';} + else input.value=data.settings[key]??''; + if(type==='number'){input.step='1';input.min='1';if(key==='daily_limit') input.max='100';if(key==='monthly_limit') input.max='3000';if(key==='imap_port') input.max='65535';} + inputs[key]=input; wrap.append(input); fieldset.append(wrap); + } form.append(fieldset); + } + const message=document.createElement('p'); message.setAttribute('role','status'); form.append(message); + const save=document.createElement('button'); save.type='submit'; save.textContent='保存'; form.append(save); + const close=document.createElement('button'); close.type='button'; close.textContent='关闭';close.addEventListener('click',()=>dialog.close());form.append(close); + const history=document.createElement('pre');history.textContent='最近邮件:\n'+data.messages.map(m=>`${m.created_at} · ${m.purpose} · ${m.channel||'待选择'} · ${m.status}${m.fallback_reason?' · '+m.fallback_reason:''}`).join('\n');dialog.append(history); + for(const incoming of data.messages.filter(m=>m.direction==='in')) { + const view=document.createElement('button'); view.type='button'; view.textContent='查看来信:'+incoming.email; + const content=document.createElement('pre'); dialog.append(view,content); + view.addEventListener('click',async()=>{view.disabled=true;try{const mail=await api('mailFlowIncoming?id='+encodeURIComponent(incoming.id));content.textContent=mail.subject+'\n\n'+mail.text;}catch(error){content.textContent=error.message;}finally{view.disabled=false;}}); + } + const reports=document.createElement('pre'); reports.textContent='最近汇报:\n'+data.reports.map(r=>r.report_date+' · '+r.status).join('\n');dialog.append(reports); + form.addEventListener('submit',async event=>{ + event.preventDefault();if(!form.reportValidity()) return; + if((inputs.enabled.checked || Number(tier.value)===2) && !data.database_ready){message.textContent='请先执行数据库迁移。';return;} + if(inputs.inbound_enabled.checked && !data.imap_available){message.textContent='请先安装 PHP IMAP 扩展。';return;} + const values={};for(const [key,input] of Object.entries(inputs)){ + if(input.type==='checkbox') values[key]=input.checked; + else if(input.type==='password' && !input.value) continue; + else values[key]=input.type==='number'?Number(input.value):input.value; + } + save.disabled=true;try{await api('save',{mail_flow:values,automatic_mail_level:Number(tier.value)});message.textContent='配置已保存。实际收发结果请查看邮件状态。';}catch(error){message.textContent=error.message;}finally{save.disabled=false;} + }); + } catch(error) {dialog.textContent=error.message;const close=document.createElement('button');close.textContent='关闭';close.addEventListener('click',()=>dialog.close());dialog.append(close);} + finally {entry.disabled=false;} +}); diff --git a/resources/views/admin.blade.php b/resources/views/admin.blade.php index cfd3d09..f8e7285 100644 --- a/resources/views/admin.blade.php +++ b/resources/views/admin.blade.php @@ -82,6 +82,7 @@
+