feat(mail): add Resend delivery, inbox replies and notification tiers
This commit is contained in:
parent
208536e1cb
commit
ad42a0e1e7
1
.gitignore
vendored
1
.gitignore
vendored
@ -47,3 +47,4 @@ compose.split.yaml
|
||||
/macos-client/.build/
|
||||
|
||||
/macos-client/.swiftpm/
|
||||
/storage/app/private/
|
||||
|
||||
32
app/Console/Commands/PollMailInbox.php
Normal file
32
app/Console/Commands/PollMailInbox.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\MailFlow\ImapInbox;
|
||||
use App\Services\MailFlow\Settings;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class PollMailInbox extends Command
|
||||
{
|
||||
protected $signature = 'mail:poll-inbox';
|
||||
protected $description = 'Read new conventional mailbox headers and queue automatic acknowledgements';
|
||||
|
||||
public function handle(ImapInbox $inbox): int
|
||||
{
|
||||
if (!Settings::get('enabled') || !Settings::get('inbound_enabled')) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$lock = Cache::lock('mailflow:inbox', 1800);
|
||||
if (!$lock->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();
|
||||
}
|
||||
}
|
||||
}
|
||||
59
app/Console/Commands/ReconcileMail.php
Normal file
59
app/Console/Commands/ReconcileMail.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\SendMailMessageJob;
|
||||
use App\Models\MailMessage;
|
||||
use App\Services\MailFlow\DeliveryEvents;
|
||||
use App\Services\MailFlow\Settings;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ReconcileMail extends Command
|
||||
{
|
||||
protected $signature = 'mail:reconcile';
|
||||
protected $description = 'Recover persisted mail tasks and delivery callbacks without cross-provider duplicates';
|
||||
|
||||
public function handle(DeliveryEvents $events): int
|
||||
{
|
||||
$enabled = Settings::get('enabled');
|
||||
if (!$enabled && !\Illuminate\Support\Facades\Schema::hasTable('v2_mail_messages')) { return self::SUCCESS; }
|
||||
if ($enabled) {
|
||||
MailMessage::where('direction', 'event')->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;
|
||||
}
|
||||
}
|
||||
26
app/Console/Commands/ReportMailUsage.php
Normal file
26
app/Console/Commands/ReportMailUsage.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\MailDailyReport;
|
||||
use App\Services\MailFlow\DailyReportService;
|
||||
use App\Services\MailFlow\Settings;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ReportMailUsage extends Command
|
||||
{
|
||||
protected $signature = 'mail:report-usage';
|
||||
protected $description = 'Snapshot the complete 08:00-to-08:00 mail period and report to master';
|
||||
|
||||
public function handle(DailyReportService $reports): int
|
||||
{
|
||||
if (!Settings::get('enabled') || !Settings::get('report_enabled')) { return self::SUCCESS; }
|
||||
// 00:00 UTC is 08:00 Asia/Shanghai, independent of the application's timezone.
|
||||
$reports->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;
|
||||
}
|
||||
}
|
||||
22
app/Console/Commands/SendExpiredSubscriptionMail.php
Normal file
22
app/Console/Commands/SendExpiredSubscriptionMail.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\MailFlow\AutomaticMailPolicy;
|
||||
use App\Services\MailFlow\ExpiredSubscriptionNotifier;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SendExpiredSubscriptionMail extends Command
|
||||
{
|
||||
protected $signature = 'send:expiredSubscriptionMail';
|
||||
protected $description = 'Queue current tier-two subscription expiry notifications';
|
||||
|
||||
public function handle(ExpiredSubscriptionNotifier $notifier): int
|
||||
{
|
||||
$state = AutomaticMailPolicy::state();
|
||||
if ($state === null) { $this->warn('自动邮件档位暂不可读取'); return self::FAILURE; }
|
||||
if ($state['level'] !== 2) { return self::SUCCESS; }
|
||||
$this->info('套餐到期通知已派发:' . $notifier->queueDue());
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
26
app/Http/Controllers/V1/Guest/ResendController.php
Normal file
26
app/Http/Controllers/V1/Guest/ResendController.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Guest;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\MailFlow\DeliveryEvents;
|
||||
use App\Services\MailFlow\Settings;
|
||||
use App\Services\MailFlow\WebhookSignature;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ResendController extends Controller
|
||||
{
|
||||
public function webhook(Request $request, WebhookSignature $signature, DeliveryEvents $events)
|
||||
{
|
||||
abort_unless(Settings::get('enabled'), 404);
|
||||
$body = $request->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]);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
34
app/Http/Controllers/V2/Admin/MailFlowController.php
Normal file
34
app/Http/Controllers/V2/Admin/MailFlowController.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V2\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MailDailyReport;
|
||||
use App\Models\MailMessage;
|
||||
use App\Models\MailQuotaBucket;
|
||||
use App\Services\MailFlow\Settings;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class MailFlowController extends Controller
|
||||
{
|
||||
public function incoming(\Illuminate\Http\Request $request)
|
||||
{
|
||||
$data = $request->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() : [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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 分钟内点击下方链接进行登入。如果您未授权该登入请求,请无视。<a href="{{link}}">{{link}}</a>'),
|
||||
default => $layout('通知', '{{content}}'),
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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']);
|
||||
|
||||
@ -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)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
42
app/Jobs/SendMailMessageJob.php
Normal file
42
app/Jobs/SendMailMessageJob.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\MailMessage;
|
||||
use App\Services\MailFlow\Dispatcher;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class SendMailMessageJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $tries = 3;
|
||||
public $timeout = 30;
|
||||
public function __construct(public int $messageId) { $this->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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
app/Models/MailDailyReport.php
Normal file
12
app/Models/MailDailyReport.php
Normal file
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class MailDailyReport extends Model
|
||||
{
|
||||
protected $table = 'v2_mail_daily_reports';
|
||||
protected $guarded = ['id'];
|
||||
protected $casts = ['summary' => 'array', 'submitted_at' => 'datetime'];
|
||||
}
|
||||
17
app/Models/MailMessage.php
Normal file
17
app/Models/MailMessage.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class MailMessage extends Model
|
||||
{
|
||||
protected $table = 'v2_mail_messages';
|
||||
protected $guarded = ['id'];
|
||||
protected $hidden = ['payload', 'reservation'];
|
||||
protected $casts = [
|
||||
'payload' => 'encrypted:array', 'reservation' => 'array',
|
||||
'first_attempt_at' => 'datetime', 'expires_at' => 'datetime',
|
||||
'submitted_at' => 'datetime', 'delivered_at' => 'datetime', 'provider_event_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
12
app/Models/MailQuotaBucket.php
Normal file
12
app/Models/MailQuotaBucket.php
Normal file
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class MailQuotaBucket extends Model
|
||||
{
|
||||
protected $table = 'v2_mail_quota_buckets';
|
||||
protected $guarded = ['id'];
|
||||
protected $casts = ['resets_at' => 'datetime', 'synced_at' => 'datetime'];
|
||||
}
|
||||
@ -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' => [],
|
||||
|
||||
@ -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,
|
||||
|
||||
60
app/Services/MailFlow/AutoReplyService.php
Normal file
60
app/Services/MailFlow/AutoReplyService.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\MailFlow;
|
||||
|
||||
use App\Models\MailMessage;
|
||||
use App\Models\User;
|
||||
|
||||
class AutoReplyService
|
||||
{
|
||||
public function prepare(MailMessage $incoming, array $headers): ?MailMessage
|
||||
{
|
||||
if (!Settings::get('auto_reply_enabled')) {
|
||||
return null;
|
||||
}
|
||||
$sender = strtolower($incoming->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;
|
||||
}
|
||||
}
|
||||
83
app/Services/MailFlow/AutomaticMailPolicy.php
Normal file
83
app/Services/MailFlow/AutomaticMailPolicy.php
Normal file
@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\MailFlow;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AutomaticMailPolicy
|
||||
{
|
||||
// One atomic record; read directly so long-running workers never use a stale tier.
|
||||
public static function state(): ?array
|
||||
{
|
||||
try {
|
||||
$state = Setting::where('name', 'automatic_mail_policy')->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];
|
||||
}
|
||||
}
|
||||
22
app/Services/MailFlow/Branding.php
Normal file
22
app/Services/MailFlow/Branding.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\MailFlow;
|
||||
|
||||
class Branding
|
||||
{
|
||||
public static function values(): array
|
||||
{
|
||||
return ['site_url' => 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']];
|
||||
}
|
||||
}
|
||||
55
app/Services/MailFlow/ConventionalTransport.php
Normal file
55
app/Services/MailFlow/ConventionalTransport.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\MailFlow;
|
||||
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class ConventionalTransport
|
||||
{
|
||||
public function send(array $payload): array
|
||||
{
|
||||
$config = config('mail');
|
||||
foreach (['host', 'port', 'username', 'password', 'encryption'] as $key) {
|
||||
$config[$key] = admin_setting('email_' . $key, $config[$key] ?? null);
|
||||
}
|
||||
$driver = $config['driver'] ?? 'smtp';
|
||||
$config['transport'] = $driver;
|
||||
$config['timeout'] = 8;
|
||||
if (($config['encryption'] ?? null) === 'ssl') {
|
||||
$config['scheme'] = 'smtps';
|
||||
}
|
||||
if (($config['encryption'] ?? null) === 'tls') {
|
||||
$config['require_tls'] = true;
|
||||
}
|
||||
$from = admin_setting('email_from_address', $config['from']['address']);
|
||||
$name = admin_setting('app_name', $config['from']['name']);
|
||||
// Build an isolated mailer: cached worker mailers never inherit another message's credentials.
|
||||
$sent = Mail::build($config)->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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
99
app/Services/MailFlow/DailyReportService.php
Normal file
99
app/Services/MailFlow/DailyReportService.php
Normal file
@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\MailFlow;
|
||||
|
||||
use App\Models\MailDailyReport;
|
||||
use App\Models\MailMessage;
|
||||
use App\Models\MailQuotaBucket;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class DailyReportService
|
||||
{
|
||||
public function __construct(private ResendClient $resend) {}
|
||||
public function snapshot(Carbon $end): MailDailyReport
|
||||
{
|
||||
$start = $end->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();
|
||||
}
|
||||
}
|
||||
}
|
||||
124
app/Services/MailFlow/DeliveryEvents.php
Normal file
124
app/Services/MailFlow/DeliveryEvents.php
Normal file
@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\MailFlow;
|
||||
|
||||
use App\Models\MailMessage;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeliveryEvents
|
||||
{
|
||||
private const STATUSES = [
|
||||
'email.sent' => '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,
|
||||
};
|
||||
}
|
||||
}
|
||||
222
app/Services/MailFlow/Dispatcher.php
Normal file
222
app/Services/MailFlow/Dispatcher.php
Normal file
@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\MailFlow;
|
||||
|
||||
use App\Models\MailMessage;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Dispatcher
|
||||
{
|
||||
public function __construct(private ResendClient $resend, private QuotaService $quota, private ConventionalTransport $conventional) {}
|
||||
|
||||
public function send(array $params, string $subject, string $html): array
|
||||
{
|
||||
$params['purpose'] = AutomaticMailPolicy::purpose($params);
|
||||
if (!filter_var($params['email'] ?? '', FILTER_VALIDATE_EMAIL)) {
|
||||
return ['status' => '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];
|
||||
}
|
||||
}
|
||||
46
app/Services/MailFlow/ExpiredSubscriptionNotifier.php
Normal file
46
app/Services/MailFlow/ExpiredSubscriptionNotifier.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\MailFlow;
|
||||
|
||||
use App\Jobs\SendMailMessageJob;
|
||||
use App\Models\MailMessage;
|
||||
use App\Models\User;
|
||||
|
||||
class ExpiredSubscriptionNotifier
|
||||
{
|
||||
public function queueDue(): int
|
||||
{
|
||||
$state = AutomaticMailPolicy::state();
|
||||
if (!$state || $state['level'] !== 2 || !$state['since'] || !admin_setting('remind_mail_enable', false)) { return 0; }
|
||||
$queued = 0;
|
||||
User::where('remind_expire', true)->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;
|
||||
}
|
||||
}
|
||||
115
app/Services/MailFlow/ImapInbox.php
Normal file
115
app/Services/MailFlow/ImapInbox.php
Normal file
@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\MailFlow;
|
||||
|
||||
use App\Jobs\SendMailMessageJob;
|
||||
use App\Models\MailMessage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ImapInbox
|
||||
{
|
||||
public function __construct(private AutoReplyService $replies, private IncomingContent $content) {}
|
||||
|
||||
public function poll(): int
|
||||
{
|
||||
if (!function_exists('imap_open')) {
|
||||
throw new \RuntimeException('php_imap_extension_required');
|
||||
}
|
||||
$host = (string) Settings::get('imap_host');
|
||||
$folder = (string) Settings::get('imap_folder', 'INBOX');
|
||||
$port = (int) Settings::get('imap_port', 993);
|
||||
if (!preg_match('/^[a-zA-Z0-9.-]+$/', $host) || !preg_match('/^[a-zA-Z0-9_ .\/-]+$/', $folder) || $port < 1 || $port > 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;
|
||||
}
|
||||
}
|
||||
63
app/Services/MailFlow/IncomingContent.php
Normal file
63
app/Services/MailFlow/IncomingContent.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\MailFlow;
|
||||
|
||||
use App\Models\MailMessage;
|
||||
|
||||
class IncomingContent
|
||||
{
|
||||
public function read($connection, int $uid, object $structure): array
|
||||
{
|
||||
$text = ''; $htmlText = ''; $dsn = '';
|
||||
foreach ($this->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']);
|
||||
}
|
||||
}
|
||||
84
app/Services/MailFlow/QuotaService.php
Normal file
84
app/Services/MailFlow/QuotaService.php
Normal file
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\MailFlow;
|
||||
|
||||
use App\Models\MailMessage;
|
||||
use App\Models\MailQuotaBucket;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class QuotaService
|
||||
{
|
||||
public function __construct(private ResendClient $client) {}
|
||||
|
||||
public function reserve(MailMessage $message): bool
|
||||
{
|
||||
if ($message->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`')]);
|
||||
}
|
||||
}
|
||||
40
app/Services/MailFlow/ResendClient.php
Normal file
40
app/Services/MailFlow/ResendClient.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\MailFlow;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class ResendClient
|
||||
{
|
||||
private function request(): PendingRequest
|
||||
{
|
||||
return Http::baseUrl('https://api.resend.com')->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();
|
||||
}
|
||||
}
|
||||
32
app/Services/MailFlow/Settings.php
Normal file
32
app/Services/MailFlow/Settings.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\MailFlow;
|
||||
|
||||
class Settings
|
||||
{
|
||||
public static function all(): array
|
||||
{
|
||||
$settings = array_replace(config('mailflow', []), (array) admin_setting('mail_flow', []));
|
||||
foreach (['resend_key', 'resend_webhook_secret', 'imap_password'] as $key) {
|
||||
if (str_starts_with((string) ($settings[$key] ?? ''), 'encrypted:')) {
|
||||
$settings[$key] = \Illuminate\Support\Facades\Crypt::decryptString(substr($settings[$key], 10));
|
||||
}
|
||||
}
|
||||
return $settings;
|
||||
}
|
||||
|
||||
public static function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return self::all()[$key] ?? $default;
|
||||
}
|
||||
|
||||
public static function publicSettings(): array
|
||||
{
|
||||
$settings = self::all();
|
||||
foreach (['resend_key', 'resend_webhook_secret', 'imap_password'] as $key) {
|
||||
$settings[$key . '_configured'] = !empty($settings[$key]);
|
||||
unset($settings[$key]);
|
||||
}
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
24
app/Services/MailFlow/WebhookSignature.php
Normal file
24
app/Services/MailFlow/WebhookSignature.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\MailFlow;
|
||||
|
||||
class WebhookSignature
|
||||
{
|
||||
public function valid(string $body, string $id, string $timestamp, string $signatures, string $secret): bool
|
||||
{
|
||||
if (!$id || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
);
|
||||
if (str_contains($html, 'cid:site-logo@dpl')) {
|
||||
$params['attachments'] = \App\Services\MailFlow\Branding::attachments();
|
||||
}
|
||||
$error = null;
|
||||
} catch (\Exception $e) {
|
||||
Log::error($e);
|
||||
$error = $e->getMessage();
|
||||
$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
|
||||
];
|
||||
try {
|
||||
MailLog::create($log);
|
||||
return $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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -49,7 +49,11 @@ class TicketService
|
||||
throw new ApiException('工单回复失败');
|
||||
}
|
||||
HookManager::call('ticket.reply.admin.after', [$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'),
|
||||
|
||||
26
config/mailflow.php
Normal file
26
config/mailflow.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'enabled' => 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'),
|
||||
];
|
||||
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('v2_mail_messages', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
80
public/admin-custom/mail-flow.js
vendored
Normal file
80
public/admin-custom/mail-flow.js
vendored
Normal file
@ -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;}
|
||||
});
|
||||
BIN
public/images/mail-logo.png
Normal file
BIN
public/images/mail-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
80
resources/js/admin/mail-flow.js
vendored
Normal file
80
resources/js/admin/mail-flow.js
vendored
Normal file
@ -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;}
|
||||
});
|
||||
@ -82,6 +82,7 @@
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/admin-custom/plan-device-display.js?v={{ filemtime(public_path('admin-custom/plan-device-display.js')) }}"></script>
|
||||
<script type="module" src="/admin-custom/mail-flow.js?v={{ filemtime(public_path('admin-custom/mail-flow.js')) }}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@ -13,7 +13,14 @@
|
||||
<!-- Brand header: solid color and real text remain useful with images disabled. -->
|
||||
<tr><td bgcolor="#142542" style="background-color:#142542;border-radius:18px;padding:28px 24px;">
|
||||
<table role="presentation" border="0" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed;">
|
||||
<tr><td style="font-size:20px;line-height:28px;font-weight:700;color:#ffffff;word-break:break-word;overflow-wrap:anywhere;">{{$name}}</td></tr>
|
||||
<tr><td>
|
||||
<table role="presentation" border="0" width="100%" cellpadding="0" cellspacing="0"><tr>
|
||||
@if(!empty($logo_src))
|
||||
<td width="52" valign="middle" style="width:52px;padding-right:8px;"><img src="{{$logo_src}}" alt="{{$name}}" width="44" height="44" style="display:block;border:0;width:44px;height:44px;" /></td>
|
||||
@endif
|
||||
<td valign="middle" style="font-size:20px;line-height:28px;font-weight:700;color:#ffffff;word-break:break-word;overflow-wrap:anywhere;">{{$name}}</td>
|
||||
</tr></table>
|
||||
</td></tr>
|
||||
<tr><td style="padding-top:6px;font-size:13px;line-height:21px;color:#becde2;">账户与服务通知</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
@ -35,7 +42,7 @@
|
||||
</td></tr>
|
||||
<!-- Footer -->
|
||||
<tr><td style="padding-top:24px;text-align:center;">
|
||||
<a href="{{$url}}" style="font-size:13px;line-height:21px;color:#236bd3;text-decoration:none;word-break:break-all;overflow-wrap:anywhere;">{{$url}}</a>
|
||||
<a href="{{$site_url ?? $url}}" style="font-size:13px;line-height:21px;color:#236bd3;text-decoration:none;word-break:break-all;overflow-wrap:anywhere;">{{$site_url ?? $url}}</a>
|
||||
<p style="font-size:12px;color:#6a7b93;margin:8px 0 0;">此邮件由系统自动发送,请勿直接回复。</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
@ -13,7 +13,14 @@
|
||||
<!-- Brand header: solid color and real text remain useful with images disabled. -->
|
||||
<tr><td bgcolor="#142542" style="background-color:#142542;border-radius:18px;padding:28px 24px;">
|
||||
<table role="presentation" border="0" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed;">
|
||||
<tr><td style="font-size:20px;line-height:28px;font-weight:700;color:#ffffff;word-break:break-word;overflow-wrap:anywhere;">{{$name}}</td></tr>
|
||||
<tr><td>
|
||||
<table role="presentation" border="0" width="100%" cellpadding="0" cellspacing="0"><tr>
|
||||
@if(!empty($logo_src))
|
||||
<td width="52" valign="middle" style="width:52px;padding-right:8px;"><img src="{{$logo_src}}" alt="{{$name}}" width="44" height="44" style="display:block;border:0;width:44px;height:44px;" /></td>
|
||||
@endif
|
||||
<td valign="middle" style="font-size:20px;line-height:28px;font-weight:700;color:#ffffff;word-break:break-word;overflow-wrap:anywhere;">{{$name}}</td>
|
||||
</tr></table>
|
||||
</td></tr>
|
||||
<tr><td style="padding-top:6px;font-size:13px;line-height:21px;color:#becde2;">账户与服务通知</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
@ -33,7 +40,7 @@
|
||||
</td></tr>
|
||||
<!-- Footer -->
|
||||
<tr><td style="padding-top:24px;text-align:center;">
|
||||
<a href="{{$url}}" style="font-size:13px;line-height:21px;color:#236bd3;text-decoration:none;word-break:break-all;overflow-wrap:anywhere;">{{$url}}</a>
|
||||
<a href="{{$site_url ?? $url}}" style="font-size:13px;line-height:21px;color:#236bd3;text-decoration:none;word-break:break-all;overflow-wrap:anywhere;">{{$site_url ?? $url}}</a>
|
||||
<p style="font-size:12px;color:#6a7b93;margin:8px 0 0;">此邮件由系统自动发送,请勿直接回复。</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
@ -13,7 +13,14 @@
|
||||
<!-- Brand header: solid color and real text remain useful with images disabled. -->
|
||||
<tr><td bgcolor="#142542" style="background-color:#142542;border-radius:18px;padding:28px 24px;">
|
||||
<table role="presentation" border="0" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed;">
|
||||
<tr><td style="font-size:20px;line-height:28px;font-weight:700;color:#ffffff;word-break:break-word;overflow-wrap:anywhere;">{{$name}}</td></tr>
|
||||
<tr><td>
|
||||
<table role="presentation" border="0" width="100%" cellpadding="0" cellspacing="0"><tr>
|
||||
@if(!empty($logo_src))
|
||||
<td width="52" valign="middle" style="width:52px;padding-right:8px;"><img src="{{$logo_src}}" alt="{{$name}}" width="44" height="44" style="display:block;border:0;width:44px;height:44px;" /></td>
|
||||
@endif
|
||||
<td valign="middle" style="font-size:20px;line-height:28px;font-weight:700;color:#ffffff;word-break:break-word;overflow-wrap:anywhere;">{{$name}}</td>
|
||||
</tr></table>
|
||||
</td></tr>
|
||||
<tr><td style="padding-top:6px;font-size:13px;line-height:21px;color:#becde2;">账户与服务通知</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
@ -34,7 +41,7 @@
|
||||
</td></tr>
|
||||
<!-- Footer -->
|
||||
<tr><td style="padding-top:24px;text-align:center;">
|
||||
<a href="{{$url}}" style="font-size:13px;line-height:21px;color:#236bd3;text-decoration:none;word-break:break-all;overflow-wrap:anywhere;">{{$url}}</a>
|
||||
<a href="{{$site_url ?? $url}}" style="font-size:13px;line-height:21px;color:#236bd3;text-decoration:none;word-break:break-all;overflow-wrap:anywhere;">{{$site_url ?? $url}}</a>
|
||||
<p style="font-size:12px;color:#6a7b93;margin:8px 0 0;">此邮件由系统自动发送,请勿直接回复。</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
@ -13,7 +13,14 @@
|
||||
<!-- Brand header: solid color and real text remain useful with images disabled. -->
|
||||
<tr><td bgcolor="#142542" style="background-color:#142542;border-radius:18px;padding:28px 24px;">
|
||||
<table role="presentation" border="0" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed;">
|
||||
<tr><td style="font-size:20px;line-height:28px;font-weight:700;color:#ffffff;word-break:break-word;overflow-wrap:anywhere;">{{$name}}</td></tr>
|
||||
<tr><td>
|
||||
<table role="presentation" border="0" width="100%" cellpadding="0" cellspacing="0"><tr>
|
||||
@if(!empty($logo_src))
|
||||
<td width="52" valign="middle" style="width:52px;padding-right:8px;"><img src="{{$logo_src}}" alt="{{$name}}" width="44" height="44" style="display:block;border:0;width:44px;height:44px;" /></td>
|
||||
@endif
|
||||
<td valign="middle" style="font-size:20px;line-height:28px;font-weight:700;color:#ffffff;word-break:break-word;overflow-wrap:anywhere;">{{$name}}</td>
|
||||
</tr></table>
|
||||
</td></tr>
|
||||
<tr><td style="padding-top:6px;font-size:13px;line-height:21px;color:#becde2;">账户与服务通知</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
@ -34,7 +41,7 @@
|
||||
</td></tr>
|
||||
<!-- Footer -->
|
||||
<tr><td style="padding-top:24px;text-align:center;">
|
||||
<a href="{{$url}}" style="font-size:13px;line-height:21px;color:#236bd3;text-decoration:none;word-break:break-all;overflow-wrap:anywhere;">{{$url}}</a>
|
||||
<a href="{{$site_url ?? $url}}" style="font-size:13px;line-height:21px;color:#236bd3;text-decoration:none;word-break:break-all;overflow-wrap:anywhere;">{{$site_url ?? $url}}</a>
|
||||
<p style="font-size:12px;color:#6a7b93;margin:8px 0 0;">此邮件由系统自动发送,请勿直接回复。</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
52
resources/views/mail/default/serviceExpired.blade.php
Normal file
52
resources/views/mail/default/serviceExpired.blade.php
Normal file
@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>套餐已到期</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#f1f5fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',Arial,sans-serif;">
|
||||
<table role="presentation" border="0" width="100%" cellpadding="0" cellspacing="0" bgcolor="#f1f5fb" style="background-color:#f1f5fb;">
|
||||
<tr><td align="center" style="padding:28px 16px;">
|
||||
<!--[if mso]><table role="presentation" border="0" width="600" align="center" cellpadding="0" cellspacing="0"><tr><td><![endif]-->
|
||||
<table role="presentation" border="0" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;table-layout:fixed;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',Arial,sans-serif;">
|
||||
<!-- Brand header: solid color and real text remain useful with images disabled. -->
|
||||
<tr><td bgcolor="#142542" style="background-color:#142542;border-radius:18px;padding:28px 24px;">
|
||||
<table role="presentation" border="0" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed;">
|
||||
<tr><td>
|
||||
<table role="presentation" border="0" width="100%" cellpadding="0" cellspacing="0"><tr>
|
||||
@if(!empty($logo_src))
|
||||
<td width="52" valign="middle" style="width:52px;padding-right:8px;"><img src="{{$logo_src}}" alt="{{$name}}" width="44" height="44" style="display:block;border:0;width:44px;height:44px;" /></td>
|
||||
@endif
|
||||
<td valign="middle" style="font-size:20px;line-height:28px;font-weight:700;color:#ffffff;word-break:break-word;overflow-wrap:anywhere;">{{$name}}</td>
|
||||
</tr></table>
|
||||
</td></tr>
|
||||
<tr><td style="padding-top:6px;font-size:13px;line-height:21px;color:#becde2;">账户与服务通知</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td height="18" style="height:18px;font-size:0;line-height:0;"> </td></tr>
|
||||
<!-- Content card; square corners are an acceptable fallback in classic Outlook. -->
|
||||
<tr><td bgcolor="#ffffff" style="background-color:#ffffff;border-radius:18px;border:1px solid #e1e8f2;padding:28px 24px;">
|
||||
<table role="presentation" border="0" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed;">
|
||||
<tr><td style="font-size:26px;line-height:34px;font-weight:700;color:#17253f;padding-bottom:12px;">套餐已到期</td></tr>
|
||||
<tr><td style="font-size:15px;color:#3a567c;line-height:1.7;padding-bottom:12px;">您的套餐已到期。</td></tr>
|
||||
<tr><td style="font-size:15px;color:#3a567c;line-height:1.7;padding-bottom:28px;">如需继续使用,请登录网站续费。</td></tr>
|
||||
<tr><td align="center">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" align="center"><tr><td align="center" bgcolor="#3278e9" style="background-color:#3278e9;border-radius:8px;">
|
||||
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{$url}}" style="height:48px;v-text-anchor:middle;width:200px;" arcsize="16%" stroke="f" fillcolor="#3278e9"><w:anchorlock/><center style="color:#ffffff;font-family:'Microsoft YaHei',Arial,sans-serif;font-size:15px;font-weight:bold;">立即续费</center></v:roundrect><![endif]-->
|
||||
<!--[if !mso]><!--><a href="{{$url}}" style="display:inline-block;width:200px;line-height:48px;background-color:#3278e9;color:#ffffff;font-size:15px;font-weight:600;text-align:center;text-decoration:none;border-radius:8px;">立即续费</a><!--<![endif]-->
|
||||
</td></tr></table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<!-- Footer -->
|
||||
<tr><td style="padding-top:24px;text-align:center;">
|
||||
<a href="{{$site_url ?? $url}}" style="font-size:13px;line-height:21px;color:#236bd3;text-decoration:none;word-break:break-all;overflow-wrap:anywhere;">{{$site_url ?? $url}}</a>
|
||||
<p style="font-size:12px;color:#6a7b93;margin:8px 0 0;">此邮件由系统自动发送,请勿直接回复。</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
<!--[if mso]></td></tr></table><![endif]-->
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@ -13,7 +13,14 @@
|
||||
<!-- Brand header: solid color and real text remain useful with images disabled. -->
|
||||
<tr><td bgcolor="#142542" style="background-color:#142542;border-radius:18px;padding:28px 24px;">
|
||||
<table role="presentation" border="0" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed;">
|
||||
<tr><td style="font-size:20px;line-height:28px;font-weight:700;color:#ffffff;word-break:break-word;overflow-wrap:anywhere;">{{$name}}</td></tr>
|
||||
<tr><td>
|
||||
<table role="presentation" border="0" width="100%" cellpadding="0" cellspacing="0"><tr>
|
||||
@if(!empty($logo_src))
|
||||
<td width="52" valign="middle" style="width:52px;padding-right:8px;"><img src="{{$logo_src}}" alt="{{$name}}" width="44" height="44" style="display:block;border:0;width:44px;height:44px;" /></td>
|
||||
@endif
|
||||
<td valign="middle" style="font-size:20px;line-height:28px;font-weight:700;color:#ffffff;word-break:break-word;overflow-wrap:anywhere;">{{$name}}</td>
|
||||
</tr></table>
|
||||
</td></tr>
|
||||
<tr><td style="padding-top:6px;font-size:13px;line-height:21px;color:#becde2;">账户与服务通知</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
@ -31,7 +38,7 @@
|
||||
</td></tr>
|
||||
<!-- Footer -->
|
||||
<tr><td style="padding-top:24px;text-align:center;">
|
||||
<a href="{{$url}}" style="font-size:13px;line-height:21px;color:#236bd3;text-decoration:none;word-break:break-all;overflow-wrap:anywhere;">{{$url}}</a>
|
||||
<a href="{{$site_url ?? $url}}" style="font-size:13px;line-height:21px;color:#236bd3;text-decoration:none;word-break:break-all;overflow-wrap:anywhere;">{{$site_url ?? $url}}</a>
|
||||
<p style="font-size:12px;color:#6a7b93;margin:8px 0 0;">此邮件由系统自动发送,请勿直接回复。</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
271
tests/Feature/MailFlow/AutomaticMailPolicyTest.php
Normal file
271
tests/Feature/MailFlow/AutomaticMailPolicyTest.php
Normal file
@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\MailFlow;
|
||||
|
||||
use App\Jobs\SendEmailJob;
|
||||
use App\Jobs\SendMailMessageJob;
|
||||
use App\Models\MailMessage;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Services\MailFlow\AutomaticMailPolicy;
|
||||
use App\Services\MailFlow\ConventionalTransport;
|
||||
use App\Services\MailFlow\DailyReportService;
|
||||
use App\Services\MailFlow\Dispatcher;
|
||||
use App\Services\MailFlow\ExpiredSubscriptionNotifier;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AutomaticMailPolicyTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
|
||||
'cache.default' => 'array', 'cache.stores.redis' => ['driver' => 'array'],
|
||||
'app.key' => 'base64:' . base64_encode(str_repeat('a', 32)), 'queue.default' => 'sync',
|
||||
'mailflow.enabled' => true, 'mailflow.resend_enabled' => false]);
|
||||
DB::purge('sqlite');
|
||||
Cache::forgetDriver(['redis', 'array']);
|
||||
$this->app->forgetInstance(\App\Support\Setting::class);
|
||||
Schema::create('v2_settings', fn (Blueprint $t) => [$t->id(), $t->string('name'), $t->text('value')->nullable(), $t->timestamps()]);
|
||||
Schema::create('v2_user', function (Blueprint $t) {
|
||||
$t->id(); $t->string('email'); $t->boolean('banned')->default(false);
|
||||
$t->boolean('remind_expire')->default(true); $t->integer('plan_id')->nullable();
|
||||
$t->integer('expired_at')->nullable(); $t->integer('created_at')->nullable(); $t->integer('updated_at')->nullable();
|
||||
});
|
||||
Schema::create('v2_mail_templates', fn (Blueprint $t) => [$t->id(), $t->string('name'), $t->string('subject'), $t->text('content'), $t->timestamps()]);
|
||||
Schema::create('v2_mail_log', fn (Blueprint $t) => [$t->id(), $t->string('email'), $t->string('subject'), $t->string('template_name'), $t->string('error')->nullable(), $t->integer('created_at')->nullable(), $t->integer('updated_at')->nullable()]);
|
||||
(require base_path('database/migrations/2026_10_09_170000_create_mail_flow_tables.php'))->up();
|
||||
Http::preventStrayRequests();
|
||||
Bus::fake([SendMailMessageJob::class, SendEmailJob::class]);
|
||||
$this->travelTo(\Carbon\Carbon::parse('2026-10-10 12:00:00', 'Asia/Shanghai'));
|
||||
admin_setting(['remind_mail_enable' => 1, 'app_url' => 'https://example.com']);
|
||||
}
|
||||
|
||||
private function transport(int $times): void
|
||||
{
|
||||
$transport = Mockery::mock(ConventionalTransport::class);
|
||||
$transport->shouldReceive('send')->times($times)->andReturn(['status' => 'submitted', 'provider_id' => 'smtp-test']);
|
||||
$this->app->instance(ConventionalTransport::class, $transport);
|
||||
}
|
||||
|
||||
private function send(string $purpose, string $key = 'test'): array
|
||||
{
|
||||
return app(Dispatcher::class)->send(['purpose' => $purpose, 'message_key' => $key, 'email' => 'test@example.com'], 'Test', '<p>Test</p>');
|
||||
}
|
||||
|
||||
private function dueUser(int $offset = 10, array $extra = []): User
|
||||
{
|
||||
return User::withoutEvents(fn () => User::create($extra + ['email' => 'test@example.com', 'plan_id' => 1, 'expired_at' => now()->timestamp + $offset]));
|
||||
}
|
||||
|
||||
public function test_unconfigured_is_tier_one_and_does_not_write_default(): void
|
||||
{
|
||||
$this->assertSame(['level' => 1, 'since' => null], AutomaticMailPolicy::state());
|
||||
$this->assertNull(AutomaticMailPolicy::decision('expiry_reminder'));
|
||||
$this->assertSame(0, Setting::where('name', 'automatic_mail_policy')->count());
|
||||
}
|
||||
|
||||
public function test_tier_matrix_preserves_manual_and_responses(): void
|
||||
{
|
||||
$this->transport(19);
|
||||
foreach ([1, 2, 3] as $level) {
|
||||
AutomaticMailPolicy::save($level);
|
||||
foreach (['verification', 'login_link', 'ticket_reply', 'broadcast', 'auto_reply', 'admin_test'] as $purpose) {
|
||||
$this->assertSame('submitted', $this->send($purpose, "$level-$purpose")['status']);
|
||||
}
|
||||
$this->assertSame($level === 1 ? 'submitted' : 'policy_blocked', $this->send('traffic_reminder', "$level-traffic")['status']);
|
||||
}
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_legacy_channel_also_obeys_tier(): void
|
||||
{
|
||||
config(['mailflow.enabled' => false]); AutomaticMailPolicy::save(3); $this->transport(1);
|
||||
$this->assertSame('policy_blocked', $this->send('expiry_reminder')['status']);
|
||||
$this->assertSame('submitted', $this->send('verification')['status']);
|
||||
}
|
||||
|
||||
public function test_already_queued_job_is_cancelled_and_logged_after_tier_change(): void
|
||||
{
|
||||
$job = new SendEmailJob(['email' => 'test@example.com', 'subject' => 'Test', 'template_name' => 'remindExpire',
|
||||
'template_value' => ['name' => 'Test', 'url' => 'https://example.com']]);
|
||||
AutomaticMailPolicy::save(3); $this->transport(0); $job->handle();
|
||||
$this->assertSame('policy_blocked', MailMessage::first()->status);
|
||||
$this->assertSame('automatic_mail_level_blocked', DB::table('v2_mail_log')->value('error'));
|
||||
}
|
||||
|
||||
public function test_persisted_retry_is_cancelled_even_when_flow_is_disabled(): void
|
||||
{
|
||||
$m = MailMessage::create(['message_key' => 'old', 'purpose' => 'traffic_reminder', 'email' => 'test@example.com',
|
||||
'subject' => 'Test', 'payload' => ['html' => 'Test']]);
|
||||
AutomaticMailPolicy::save(3); config(['mailflow.enabled' => false]); $this->transport(0);
|
||||
(new SendMailMessageJob($m->id))->handle(app(Dispatcher::class));
|
||||
$this->assertSame('policy_blocked', $m->fresh()->status);
|
||||
AutomaticMailPolicy::save(1);
|
||||
$this->assertSame('policy_blocked', $this->send('traffic_reminder', 'old')['status']);
|
||||
}
|
||||
|
||||
public function test_unknown_and_submitted_results_are_not_rewritten_as_unsent(): void
|
||||
{
|
||||
AutomaticMailPolicy::save(3); $this->transport(0);
|
||||
foreach (['unknown', 'submitted'] as $status) {
|
||||
$m = MailMessage::create(['message_key' => $status, 'purpose' => 'expiry_reminder', 'email' => 'test@example.com',
|
||||
'subject' => 'Test', 'channel' => 'resend', 'status' => $status, 'reservation' => [999], 'payload' => ['html' => 'Test']]);
|
||||
$this->assertSame($status, $this->send('expiry_reminder', $status)['status']);
|
||||
$this->assertSame([999], $m->fresh()->reservation);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_tier_reads_are_fresh_within_same_worker_scope(): void
|
||||
{
|
||||
AutomaticMailPolicy::save(1); $this->assertNull(AutomaticMailPolicy::decision('traffic_reminder'));
|
||||
// Simulate another process updating the setting without invalidating this scope's cache.
|
||||
Setting::createOrUpdate('automatic_mail_policy', ['level' => 3, 'since' => null]);
|
||||
$this->assertSame('policy_blocked', AutomaticMailPolicy::decision('traffic_reminder')['status']);
|
||||
}
|
||||
|
||||
public function test_unavailable_policy_retries_only_automatic_messages(): void
|
||||
{
|
||||
Schema::drop('v2_settings');
|
||||
$this->assertTrue(AutomaticMailPolicy::decision('traffic_reminder')['retryable']);
|
||||
$this->assertNull(AutomaticMailPolicy::decision('verification'));
|
||||
$this->assertNull(AutomaticMailPolicy::decision('broadcast'));
|
||||
}
|
||||
|
||||
public function test_no_historical_expiry_and_exact_due_is_queued_once(): void
|
||||
{
|
||||
$this->dueUser(-1); $due = $this->dueUser(10); AutomaticMailPolicy::save(2);
|
||||
$notifier = app(ExpiredSubscriptionNotifier::class);
|
||||
$this->assertSame(0, $notifier->queueDue()); $this->travel(10)->seconds();
|
||||
$this->assertSame(1, $notifier->queueDue()); $this->assertSame(0, $notifier->queueDue());
|
||||
$this->assertSame('subscription-expired:' . $due->id . ':' . $due->expired_at, MailMessage::first()->message_key);
|
||||
Bus::assertDispatchedTimes(SendMailMessageJob::class, 1);
|
||||
}
|
||||
|
||||
public function test_renewal_cancels_old_event_and_later_expiry_is_distinct(): void
|
||||
{
|
||||
AutomaticMailPolicy::save(2); $user = $this->dueUser(); $this->travel(10)->seconds();
|
||||
$notifier = app(ExpiredSubscriptionNotifier::class); $this->assertSame(1, $notifier->queueDue());
|
||||
$old = MailMessage::first(); User::withoutEvents(fn () => $user->update(['expired_at' => now()->timestamp + 10])); $this->transport(0);
|
||||
(new SendMailMessageJob($old->id))->handle(app(Dispatcher::class));
|
||||
$this->assertSame('policy_blocked', $old->fresh()->status);
|
||||
$this->travel(10)->seconds(); $this->assertSame(1, $notifier->queueDue());
|
||||
$this->assertSame(2, MailMessage::count());
|
||||
}
|
||||
|
||||
public function test_expiry_sends_in_legacy_mode_and_keeps_original_template_separate(): void
|
||||
{
|
||||
config(['mailflow.enabled' => false]); AutomaticMailPolicy::save(2); $this->dueUser(); $this->travel(10)->seconds();
|
||||
app(ExpiredSubscriptionNotifier::class)->queueDue(); $m = MailMessage::first(); $this->transport(1);
|
||||
(new SendMailMessageJob($m->id))->handle(app(Dispatcher::class));
|
||||
$this->assertSame('submitted', $m->fresh()->status);
|
||||
$this->assertSame('conventional', $m->fresh()->channel);
|
||||
$this->assertSame('mail.default.serviceExpired', DB::table('v2_mail_log')->value('template_name'));
|
||||
$html = view('mail.default.serviceExpired', ['name' => 'Test', 'url' => 'https://example.com'])->render();
|
||||
$this->assertStringContainsString('您的套餐已到期', $html);
|
||||
$this->assertStringNotContainsString('24 小时', $html);
|
||||
$this->assertSame('submitted', $m->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_user_preferences_and_banned_or_no_plan_users_are_excluded(): void
|
||||
{
|
||||
AutomaticMailPolicy::save(2);
|
||||
$this->dueUser(10, ['remind_expire' => false]); $this->dueUser(10, ['banned' => true]);
|
||||
$this->dueUser(10, ['plan_id' => null]); $this->travel(10)->seconds();
|
||||
$this->assertSame(0, app(ExpiredSubscriptionNotifier::class)->queueDue());
|
||||
Bus::assertNothingDispatched();
|
||||
}
|
||||
|
||||
public function test_resaving_tier_two_keeps_start_and_reentering_does_not_backfill(): void
|
||||
{
|
||||
AutomaticMailPolicy::save(2); $since = AutomaticMailPolicy::state()['since']; $this->dueUser();
|
||||
$this->travel(10)->seconds(); AutomaticMailPolicy::save(2);
|
||||
$this->assertSame($since, AutomaticMailPolicy::state()['since']);
|
||||
AutomaticMailPolicy::save(3); AutomaticMailPolicy::save(2);
|
||||
$this->assertSame(0, app(ExpiredSubscriptionNotifier::class)->queueDue());
|
||||
}
|
||||
|
||||
public function test_reconcile_recovers_expiry_without_enabling_resend(): void
|
||||
{
|
||||
config(['mailflow.enabled' => false]); AutomaticMailPolicy::save(2); $this->dueUser(); $this->travel(10)->seconds();
|
||||
app(ExpiredSubscriptionNotifier::class)->queueDue(); $this->travel(3)->minutes();
|
||||
$this->artisan('mail:reconcile')->assertSuccessful();
|
||||
Bus::assertDispatchedTimes(SendMailMessageJob::class, 2);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_policy_cancellations_are_not_reported_as_pending_or_fallback(): void
|
||||
{
|
||||
AutomaticMailPolicy::save(3); $this->transport(0); $this->send('traffic_reminder'); $this->travel(1)->seconds();
|
||||
$report = app(DailyReportService::class)->snapshot(now());
|
||||
$this->assertSame(['out/policy/policy_blocked' => 1], $report->summary['counts']);
|
||||
$this->assertSame(0, $report->summary['backlog']); $this->assertSame(0, $report->summary['fallbacks']);
|
||||
}
|
||||
|
||||
public function test_blocked_pending_releases_reservation_without_consuming_quota(): void
|
||||
{
|
||||
$bucket = \App\Models\MailQuotaBucket::create(['period_key' => 'daily-test', 'period' => 'daily',
|
||||
'resets_at' => now()->addDay(), 'limit' => 100, 'used' => 7, 'reserved' => 1]);
|
||||
MailMessage::create(['message_key' => 'reserved', 'purpose' => 'traffic_reminder', 'email' => 'test@example.com',
|
||||
'subject' => 'Test', 'reservation' => [$bucket->id], 'payload' => ['html' => 'Test']]);
|
||||
AutomaticMailPolicy::save(3); $this->transport(0);
|
||||
$this->assertSame('policy_blocked', $this->send('traffic_reminder', 'reserved')['status']);
|
||||
$this->assertSame(7, $bucket->fresh()->used); $this->assertSame(0, $bucket->fresh()->reserved);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_protected_mail_keeps_resend_route_and_automatic_block_never_reserves(): void
|
||||
{
|
||||
AutomaticMailPolicy::save(3); config(['mailflow.resend_enabled' => true, 'mailflow.resend_key' => 'fake-key',
|
||||
'mailflow.resend_from' => 'sender@example.com']); $this->transport(0);
|
||||
$quota = Mockery::mock(\App\Services\MailFlow\QuotaService::class);
|
||||
$quota->shouldReceive('reserve')->once()->andReturn(true);
|
||||
$quota->shouldReceive('settle')->once()->with(Mockery::type(MailMessage::class), false);
|
||||
$quota->shouldReceive('settle')->once()->with(Mockery::type(MailMessage::class), true);
|
||||
$this->app->instance(\App\Services\MailFlow\QuotaService::class, $quota);
|
||||
Http::fake(['*/emails' => Http::response(['id' => 'fake-id'])]);
|
||||
$this->assertSame('policy_blocked', $this->send('expiry_reminder', 'blocked')['status']);
|
||||
$this->assertSame('resend', $this->send('verification', 'protected')['channel']);
|
||||
Http::assertSentCount(1);
|
||||
}
|
||||
|
||||
public function test_config_validation_and_atomic_state_preserve_mailflow_settings(): void
|
||||
{
|
||||
$rules = (new \App\Http\Requests\Admin\ConfigSave())->rules();
|
||||
foreach ([0, 4, 'invalid', null] as $invalid) {
|
||||
$this->assertTrue(\Illuminate\Support\Facades\Validator::make(['automatic_mail_level' => $invalid], $rules)->fails());
|
||||
}
|
||||
admin_setting(['mail_flow' => ['enabled' => false, 'resend_key' => 'unchanged-test']]);
|
||||
AutomaticMailPolicy::save(2);
|
||||
$this->assertSame(['enabled' => false, 'resend_key' => 'unchanged-test'], admin_setting('mail_flow'));
|
||||
$this->assertSame(2, AutomaticMailPolicy::state()['level']);
|
||||
}
|
||||
|
||||
public function test_actual_config_save_and_status_return_tier_without_overwriting_mail_settings(): void
|
||||
{
|
||||
admin_setting(['mail_flow' => ['enabled' => false, 'resend_key' => 'unchanged-test']]);
|
||||
$request = \App\Http\Requests\Admin\ConfigSave::create('/config/save', 'POST', ['automatic_mail_level' => 2]);
|
||||
$request->setContainer($this->app); $request->setRedirector(app('redirect')); $request->validateResolved();
|
||||
(new \App\Http\Controllers\V2\Admin\ConfigController())->save($request);
|
||||
$this->assertSame(2, AutomaticMailPolicy::state()['level']);
|
||||
$response = (new \App\Http\Controllers\V2\Admin\MailFlowController())->status();
|
||||
$this->assertSame(2, $response->getData(true)['data']['automatic_mail_level']);
|
||||
$this->assertSame(['enabled' => false, 'resend_key' => 'unchanged-test'], admin_setting('mail_flow'));
|
||||
}
|
||||
|
||||
public function test_changed_reminder_preference_cancels_pending_expiry(): void
|
||||
{
|
||||
AutomaticMailPolicy::save(2); $user = $this->dueUser(); $this->travel(10)->seconds();
|
||||
app(ExpiredSubscriptionNotifier::class)->queueDue(); $m = MailMessage::first();
|
||||
User::withoutEvents(fn () => $user->update(['remind_expire' => false])); $this->transport(0);
|
||||
(new SendMailMessageJob($m->id))->handle(app(Dispatcher::class));
|
||||
$this->assertSame('policy_blocked', $m->fresh()->status);
|
||||
}
|
||||
}
|
||||
494
tests/Feature/MailFlow/MailFlowTest.php
Normal file
494
tests/Feature/MailFlow/MailFlowTest.php
Normal file
@ -0,0 +1,494 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\MailFlow;
|
||||
|
||||
use App\Models\MailMessage;
|
||||
use App\Models\MailQuotaBucket;
|
||||
use App\Models\User;
|
||||
use App\Services\MailFlow\AutoReplyService;
|
||||
use App\Services\MailFlow\ConventionalTransport;
|
||||
use App\Services\MailFlow\DailyReportService;
|
||||
use App\Services\MailFlow\DeliveryEvents;
|
||||
use App\Services\MailFlow\Dispatcher;
|
||||
use App\Services\MailFlow\QuotaService;
|
||||
use App\Services\MailFlow\Settings;
|
||||
use App\Services\MailFlow\WebhookSignature;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MailFlowTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
|
||||
'cache.default' => 'array', 'cache.stores.redis' => ['driver' => 'array'],
|
||||
'app.key' => 'base64:' . base64_encode(str_repeat('a', 32)), 'queue.default' => 'sync']);
|
||||
DB::purge('sqlite');
|
||||
Cache::forgetDriver(['redis', 'array']);
|
||||
$this->app->forgetInstance(\App\Support\Setting::class);
|
||||
Schema::create('v2_settings', fn (Blueprint $t) => [$t->id(), $t->string('name'), $t->text('value')->nullable(), $t->timestamps()]);
|
||||
Schema::create('v2_user', fn (Blueprint $t) => [$t->id(), $t->string('email'), $t->boolean('banned')->default(false), $t->integer('created_at')->nullable(), $t->integer('updated_at')->nullable()]);
|
||||
(require base_path('database/migrations/2026_10_09_170000_create_mail_flow_tables.php'))->up();
|
||||
Cache::flush();
|
||||
config(['mailflow.enabled' => true, 'mailflow.resend_enabled' => true, 'mailflow.resend_key' => 'test-key',
|
||||
'mailflow.resend_from' => 'sender@example.com', 'mailflow.reply_to' => 'support@example.com',
|
||||
'mailflow.daily_limit' => 100, 'mailflow.monthly_limit' => 3000]);
|
||||
Http::preventStrayRequests();
|
||||
}
|
||||
|
||||
private function usage(int $daily = 0, int $monthly = 0): array
|
||||
{
|
||||
return ['emails' => [
|
||||
'daily' => ['used' => $daily, 'limit' => 100, 'resets_at' => now()->utc()->addDay()->startOfDay()->toIso8601String()],
|
||||
'monthly' => ['used' => $monthly, 'limit' => 3000, 'resets_at' => now()->utc()->addMonth()->startOfMonth()->toIso8601String()],
|
||||
]];
|
||||
}
|
||||
|
||||
private function conventional(int $times = 1, string $status = 'submitted'): void
|
||||
{
|
||||
$transport = Mockery::mock(ConventionalTransport::class);
|
||||
$transport->shouldReceive('send')->times($times)->andReturn(['status' => $status, 'provider_id' => 'smtp-message']);
|
||||
$this->app->instance(ConventionalTransport::class, $transport);
|
||||
}
|
||||
|
||||
private function send(string $key = 'test-key', string $purpose = 'verification', array $extra = []): array
|
||||
{
|
||||
return app(Dispatcher::class)->send($extra + ['message_key' => $key, 'purpose' => $purpose, 'email' => 'user@example.com'], 'Test', '<p>123456</p>');
|
||||
}
|
||||
|
||||
public function test_verification_uses_resend_and_duplicate_job_sends_only_once(): void
|
||||
{
|
||||
Http::fake(['*/usage' => Http::response($this->usage()), '*/emails' => Http::response(['id' => 'resend-1'])]);
|
||||
$this->conventional(0);
|
||||
$this->assertSame('submitted', $this->send()['status']);
|
||||
$this->assertSame('submitted', $this->send()['status']);
|
||||
Http::assertSentCount(2);
|
||||
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/emails') && $r->hasHeader('Idempotency-Key', 'dpl-mail/test-key') && $r['reply_to'] === 'support@example.com');
|
||||
$this->assertSame(1, MailQuotaBucket::where('period', 'daily')->first()->used);
|
||||
$this->assertNull(MailMessage::where('direction', 'out')->first()->payload);
|
||||
}
|
||||
|
||||
public function test_ticket_reply_uses_resend_but_broadcast_and_reminders_do_not(): void
|
||||
{
|
||||
Http::fake(['*/usage' => Http::response($this->usage()), '*/emails' => Http::response(['id' => 'ticket-email'])]);
|
||||
$this->conventional(3);
|
||||
$this->assertSame('resend', $this->send('ticket', 'ticket_reply')['channel']);
|
||||
foreach (['broadcast', 'traffic_reminder', 'expiry_reminder'] as $purpose) {
|
||||
$this->assertSame('conventional', $this->send($purpose, $purpose)['channel']);
|
||||
}
|
||||
Http::assertSentCount(2);
|
||||
}
|
||||
|
||||
public function test_daily_or_monthly_exhaustion_routes_to_conventional(): void
|
||||
{
|
||||
foreach ([[100, 0], [0, 3000]] as $index => [$day, $month]) {
|
||||
Cache::forget('mailflow:resend:usage');
|
||||
Http::fake(['*/usage' => Http::response($this->usage($day, $month))]);
|
||||
$this->conventional();
|
||||
$this->assertSame('conventional', $this->send('exhausted-' . $index)['channel']);
|
||||
}
|
||||
$this->assertSame('quota_exhausted', MailMessage::where('message_key', 'exhausted-1')->first()->fallback_reason);
|
||||
Http::assertNotSent(fn ($r) => str_ends_with($r->url(), '/emails'));
|
||||
}
|
||||
|
||||
public function test_inflight_reservation_prevents_overbooking_last_quota_slot(): void
|
||||
{
|
||||
Http::fake(['*/usage' => Http::response($this->usage(99))]);
|
||||
$first = MailMessage::create(['message_key' => 'reserve-1', 'purpose' => 'verification', 'email' => 'one@example.com', 'subject' => 'Test']);
|
||||
$second = MailMessage::create(['message_key' => 'reserve-2', 'purpose' => 'verification', 'email' => 'two@example.com', 'subject' => 'Test']);
|
||||
$this->assertTrue(app(QuotaService::class)->reserve($first));
|
||||
$this->assertFalse(app(QuotaService::class)->reserve($second));
|
||||
$this->assertSame(1, MailQuotaBucket::where('period', 'daily')->first()->reserved);
|
||||
}
|
||||
|
||||
public function test_explicit_quota_rejection_falls_back_and_releases_reservation(): void
|
||||
{
|
||||
Http::fake(['*/usage' => Http::response($this->usage()), '*/emails' => Http::response(['name' => 'daily_quota_exceeded'], 429)]);
|
||||
$this->conventional();
|
||||
$this->assertSame('submitted', $this->send()['status']);
|
||||
$message = MailMessage::first();
|
||||
$this->assertSame('daily_quota_exceeded', $message->fallback_reason);
|
||||
$this->assertNull($message->reservation);
|
||||
$this->assertSame(0, MailQuotaBucket::sum('reserved'));
|
||||
}
|
||||
|
||||
public function test_timeout_remains_unknown_and_retries_same_key_on_resend_only(): void
|
||||
{
|
||||
Http::fake(['*/usage' => Http::response($this->usage()), '*/emails' => Http::failedConnection()]);
|
||||
$this->conventional(0);
|
||||
$first = $this->send();
|
||||
$this->assertSame('unknown', $first['status']);
|
||||
$this->assertTrue($first['retryable']);
|
||||
$this->assertSame(1, MailQuotaBucket::where('period', 'daily')->first()->reserved);
|
||||
Http::swap(new \Illuminate\Http\Client\Factory());
|
||||
Http::preventStrayRequests();
|
||||
Http::fake(['*/emails' => Http::response(['id' => 'recovered'])]);
|
||||
$this->assertSame('submitted', $this->send()['status']);
|
||||
$this->assertSame(1, MailQuotaBucket::where('period', 'daily')->first()->used);
|
||||
$this->assertSame(0, MailQuotaBucket::sum('reserved'));
|
||||
}
|
||||
|
||||
public function test_unknown_smtp_submission_is_never_repeated(): void
|
||||
{
|
||||
$transport = Mockery::mock(ConventionalTransport::class);
|
||||
$transport->shouldReceive('send')->once()->andThrow(new \RuntimeException('timeout'));
|
||||
$this->app->instance(ConventionalTransport::class, $transport);
|
||||
$this->assertSame('unknown', $this->send('smtp-unknown', 'broadcast')['status']);
|
||||
$this->assertSame('unknown', $this->send('smtp-unknown', 'broadcast')['status']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_usage_unavailable_uses_conventional_before_submitting_to_resend(): void
|
||||
{
|
||||
Http::fake(['*/usage' => Http::response([], 503)]);
|
||||
$this->conventional();
|
||||
$this->assertSame('conventional', $this->send()['channel']);
|
||||
$this->assertSame('quota_unavailable', MailMessage::first()->fallback_reason);
|
||||
}
|
||||
|
||||
public function test_invalid_recipient_rejection_does_not_switch_provider(): void
|
||||
{
|
||||
Http::fake(['*/usage' => Http::response($this->usage()), '*/emails' => Http::response(['name' => 'validation_error'], 422)]);
|
||||
$this->conventional(0);
|
||||
$this->assertSame('failed', $this->send()['status']);
|
||||
$this->assertSame(0, MailQuotaBucket::sum('reserved'));
|
||||
}
|
||||
|
||||
public function test_expired_verification_and_old_unknown_messages_are_not_resent(): void
|
||||
{
|
||||
$this->conventional(0);
|
||||
$this->assertSame('expired', $this->send('expired', 'verification', ['expires_at' => now()->subMinute()])['status']);
|
||||
MailMessage::create(['message_key' => 'old', 'purpose' => 'verification', 'email' => 'user@example.com', 'subject' => 'Test',
|
||||
'channel' => 'resend', 'status' => 'unknown', 'first_attempt_at' => now()->subDay(), 'payload' => ['html' => 'secret']]);
|
||||
$this->assertSame('unknown', $this->send('old')['status']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_log_transport_is_recorded_as_simulated(): void
|
||||
{
|
||||
$this->conventional(1, 'simulated');
|
||||
$this->assertSame('simulated', $this->send('log', 'broadcast')['status']);
|
||||
$this->assertNull(MailMessage::first()->submitted_at);
|
||||
}
|
||||
|
||||
public function test_webhook_signature_rejects_tampering_stale_and_missing_headers(): void
|
||||
{
|
||||
$body = '{"type":"email.delivered"}'; $id = 'event-1'; $time = (string) time();
|
||||
$key = str_repeat('b', 32); $secret = 'whsec_' . base64_encode($key);
|
||||
$signature = 'v1,' . base64_encode(hash_hmac('sha256', "$id.$time.$body", $key, true));
|
||||
$verifier = new WebhookSignature();
|
||||
$this->assertTrue($verifier->valid($body, $id, $time, $signature, $secret));
|
||||
$this->assertFalse($verifier->valid($body . ' ', $id, $time, $signature, $secret));
|
||||
$this->assertFalse($verifier->valid($body, $id, (string) (time() - 600), $signature, $secret));
|
||||
$this->assertFalse($verifier->valid($body, '', $time, $signature, $secret));
|
||||
}
|
||||
|
||||
public function test_delivery_callbacks_deduplicate_and_do_not_downgrade_delivery(): void
|
||||
{
|
||||
MailMessage::create(['message_key' => 'delivered', 'purpose' => 'verification', 'email' => 'user@example.com', 'subject' => 'Test',
|
||||
'channel' => 'resend', 'status' => 'submitted', 'provider_id' => 'email-id']);
|
||||
$events = app(DeliveryEvents::class);
|
||||
$events->receive('event-delivered', ['type' => 'email.delivered', 'created_at' => now()->toIso8601String(), 'data' => ['email_id' => 'email-id']]);
|
||||
$events->receive('event-delivered', ['type' => 'email.delivered', 'data' => ['email_id' => 'email-id']]);
|
||||
$events->receive('event-sent', ['type' => 'email.sent', 'created_at' => now()->subMinute()->toIso8601String(), 'data' => ['email_id' => 'email-id']]);
|
||||
$this->assertSame('delivered', MailMessage::where('direction', 'out')->first()->status);
|
||||
$this->assertSame(2, MailMessage::where('direction', 'event')->count());
|
||||
}
|
||||
|
||||
public function test_callback_recovers_lost_post_response_using_persisted_tag(): void
|
||||
{
|
||||
Http::fake(['*/usage' => Http::response($this->usage()), '*/emails' => Http::failedConnection()]);
|
||||
$this->conventional(0); $this->send('lost-response');
|
||||
$events = app(DeliveryEvents::class);
|
||||
$events->receive('recover-event', ['type' => 'email.delivered', 'data' => ['email_id' => 'lost-id']]);
|
||||
Http::fake(['*/emails/lost-id' => Http::response(['tags' => [['name' => 'mail_key', 'value' => 'lost-response']]])]);
|
||||
$events->apply(MailMessage::where('direction', 'event')->first(), true);
|
||||
$this->assertSame('delivered', MailMessage::where('direction', 'out')->first()->status);
|
||||
$this->assertSame(0, MailQuotaBucket::sum('reserved'));
|
||||
}
|
||||
|
||||
public function test_official_status_lookup_confirms_delivery_without_fabricating_event_time(): void
|
||||
{
|
||||
$message = MailMessage::create(['message_key' => 'lookup-delivery', 'purpose' => 'verification',
|
||||
'email' => 'user@example.com', 'subject' => 'Test', 'channel' => 'resend',
|
||||
'status' => 'submitted', 'provider_id' => 'lookup-id']);
|
||||
Http::fake(['*/emails/lookup-id' => Http::response(['id' => 'lookup-id', 'last_event' => 'delivered'])]);
|
||||
app(DeliveryEvents::class)->refreshStatus($message);
|
||||
$this->assertSame('delivered', $message->refresh()->status);
|
||||
$this->assertNull($message->delivered_at);
|
||||
$this->assertNull($message->provider_event_at);
|
||||
Http::fake(['*/emails/lookup-id' => Http::response(['id' => 'lookup-id', 'last_event' => 'sent'])]);
|
||||
app(DeliveryEvents::class)->refreshStatus($message);
|
||||
$this->assertSame('delivered', $message->refresh()->status);
|
||||
}
|
||||
|
||||
public function test_official_status_lookup_failure_keeps_submission_and_rejects_wrong_identity(): void
|
||||
{
|
||||
$message = MailMessage::create(['message_key' => 'lookup-failure', 'purpose' => 'verification',
|
||||
'email' => 'user@example.com', 'subject' => 'Test', 'channel' => 'resend',
|
||||
'status' => 'submitted', 'provider_id' => 'lookup-id']);
|
||||
foreach ([Http::response([], 429), Http::response(['id' => 'wrong-id', 'last_event' => 'delivered'])] as $response) {
|
||||
Http::fake(['*/emails/lookup-id' => $response]);
|
||||
try { app(DeliveryEvents::class)->refreshStatus($message); $this->fail('Expected unavailable or mismatched status to be rejected'); }
|
||||
catch (\RuntimeException $e) { $this->assertSame('submitted', $message->refresh()->status); }
|
||||
}
|
||||
}
|
||||
|
||||
public function test_auto_reply_uses_conventional_and_deduplicates_incoming_parent(): void
|
||||
{
|
||||
config(['mailflow.auto_reply_enabled' => true]);
|
||||
User::withoutEvents(fn () => User::create(['email' => 'user@example.com']));
|
||||
$incoming = MailMessage::create(['message_key' => 'incoming', 'direction' => 'in', 'purpose' => 'incoming', 'email' => 'user@example.com', 'subject' => 'Help', 'status' => 'received']);
|
||||
$service = app(AutoReplyService::class);
|
||||
$reply = $service->prepare($incoming, ['message-id' => '<original@example.com>']);
|
||||
$this->assertNotNull($reply);
|
||||
$this->assertSame('auto_reply', $reply->purpose);
|
||||
$this->assertNull($service->prepare($incoming, []));
|
||||
$this->conventional();
|
||||
(new \App\Jobs\SendMailMessageJob($reply->id))->handle(app(Dispatcher::class));
|
||||
Http::assertNothingSent();
|
||||
$this->assertSame('conventional', $reply->refresh()->channel);
|
||||
}
|
||||
|
||||
public function test_auto_reply_skips_automated_bulk_and_own_messages(): void
|
||||
{
|
||||
config(['mailflow.auto_reply_enabled' => true]);
|
||||
User::withoutEvents(fn () => User::create(['email' => 'user@example.com']));
|
||||
$incoming = MailMessage::create(['message_key' => 'incoming', 'direction' => 'in', 'purpose' => 'incoming', 'email' => 'user@example.com', 'subject' => 'Help']);
|
||||
foreach ([['auto-submitted' => 'auto-replied'], ['return-path' => '<>'], ['list-id' => 'list'], ['precedence' => 'bulk']] as $headers) {
|
||||
$this->assertNull(app(AutoReplyService::class)->prepare($incoming, $headers));
|
||||
}
|
||||
$incoming->email = 'support@example.com';
|
||||
$this->assertNull(app(AutoReplyService::class)->prepare($incoming, []));
|
||||
}
|
||||
|
||||
public function test_unbound_sender_receives_only_one_reply_per_beijing_calendar_day(): void
|
||||
{
|
||||
config(['mailflow.auto_reply_enabled' => true]);
|
||||
$this->travelTo(\Carbon\Carbon::parse('2026-10-10 23:59:00', 'Asia/Shanghai'));
|
||||
$service = app(AutoReplyService::class);
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$incoming = MailMessage::create(['message_key' => 'daily-' . $i, 'direction' => 'in',
|
||||
'purpose' => 'incoming', 'email' => 'stranger@example.com', 'subject' => 'Help']);
|
||||
$reply = $service->prepare($incoming, []);
|
||||
if ($i === 0) {
|
||||
$this->assertNotNull($reply);
|
||||
$this->assertNull($reply->user_id);
|
||||
$this->assertSame('2026-10-11 00:00:00', $reply->expires_at->timezone('Asia/Shanghai')->format('Y-m-d H:i:s'));
|
||||
} else {
|
||||
$this->assertNull($reply);
|
||||
}
|
||||
}
|
||||
$this->assertSame(1, MailMessage::where('purpose', 'auto_reply')->count());
|
||||
$this->travelTo(\Carbon\Carbon::parse('2026-10-11 00:01:00', 'Asia/Shanghai'));
|
||||
$this->assertNull($service->prepare(MailMessage::where('message_key', 'daily-0')->firstOrFail(), []));
|
||||
$next = MailMessage::create(['message_key' => 'next-day', 'direction' => 'in',
|
||||
'purpose' => 'incoming', 'email' => 'stranger@example.com', 'subject' => 'Help']);
|
||||
$this->assertNotNull($service->prepare($next, []));
|
||||
$this->assertSame(2, MailMessage::where('purpose', 'auto_reply')->count());
|
||||
}
|
||||
|
||||
public function test_daily_report_snapshots_exact_complete_period_and_submits_only_once(): void
|
||||
{
|
||||
$this->travelTo(\Carbon\Carbon::parse('2026-10-09 08:00:00', 'Asia/Shanghai'));
|
||||
config(['mailflow.master_url' => 'https://master.example.com', 'mailflow.master_bot_code' => 'bot', 'mailflow.master_chat_code' => 'chat']);
|
||||
MailMessage::create(['message_key' => 'previous', 'purpose' => 'verification', 'email' => 'user@example.com', 'subject' => 'Test', 'status' => 'delivered', 'channel' => 'resend', 'created_at' => now()->subMinute()]);
|
||||
MailMessage::create(['message_key' => 'next', 'purpose' => 'verification', 'email' => 'user@example.com', 'subject' => 'Test', 'created_at' => now()]);
|
||||
$service = app(DailyReportService::class);
|
||||
$report = $service->snapshot(now()->utc()->startOfDay());
|
||||
$this->assertSame(['out/resend/delivered' => 1], $report->summary['counts']);
|
||||
Http::fake(['*/api/telegram/post' => Http::response(['isDone' => true])]);
|
||||
$service->send($report); $service->send($report);
|
||||
$this->assertSame('submitted', $report->refresh()->status);
|
||||
Http::assertSentCount(1);
|
||||
}
|
||||
|
||||
public function test_master_timeout_is_unknown_and_not_automatically_repeated(): void
|
||||
{
|
||||
config(['mailflow.master_url' => 'https://master.example.com', 'mailflow.master_bot_code' => 'bot', 'mailflow.master_chat_code' => 'chat']);
|
||||
Http::fake(['*/api/telegram/post' => Http::failedConnection()]);
|
||||
$service = app(DailyReportService::class); $report = $service->snapshot(now()->utc()->startOfDay());
|
||||
$service->send($report); $service->send($report);
|
||||
$this->assertSame('unknown', $report->refresh()->status);
|
||||
$this->assertSame(1, $report->attempts);
|
||||
}
|
||||
|
||||
public function test_payload_is_encrypted_and_public_settings_hide_secrets(): void
|
||||
{
|
||||
$message = MailMessage::create(['message_key' => 'encrypted', 'purpose' => 'verification', 'email' => 'user@example.com', 'subject' => 'Test', 'payload' => ['html' => '123456-secret']]);
|
||||
$raw = DB::table('v2_mail_messages')->where('id', $message->id)->value('payload');
|
||||
$this->assertStringNotContainsString('123456-secret', $raw);
|
||||
$this->assertArrayNotHasKey('payload', $message->toArray());
|
||||
$settings = Settings::publicSettings();
|
||||
$this->assertArrayNotHasKey('resend_key', $settings);
|
||||
$this->assertTrue($settings['resend_key_configured']);
|
||||
}
|
||||
public function test_real_conventional_array_transport_preserves_reply_thread_headers(): void
|
||||
{
|
||||
config(['mail.driver' => 'array', 'mail.from.address' => 'sender@example.com', 'mail.from.name' => 'Test']);
|
||||
$result = app(ConventionalTransport::class)->send(['to' => 'user@example.com', 'subject' => 'Test',
|
||||
'html' => '<p>Test</p>', 'reply_to' => 'support@example.com',
|
||||
'headers' => ['In-Reply-To' => '<original@example.com>', 'References' => '<original@example.com>', 'Auto-Submitted' => 'auto-replied']]);
|
||||
$this->assertSame('simulated', $result['status']);
|
||||
$this->assertNotEmpty($result['provider_id']);
|
||||
}
|
||||
|
||||
public function test_correlated_permanent_bounce_updates_only_matching_smtp_message(): void
|
||||
{
|
||||
$message = MailMessage::create(['message_key' => 'smtp', 'purpose' => 'broadcast', 'email' => 'user@example.com', 'subject' => 'Test',
|
||||
'channel' => 'conventional', 'provider_id' => 'original@example.com', 'status' => 'submitted']);
|
||||
$content = app(\App\Services\MailFlow\IncomingContent::class);
|
||||
$dsn = "Action: failed\r\nFinal-Recipient: rfc822; user@example.com\r\nOriginal-Message-ID: <original@example.com>\r\n";
|
||||
$content->recordBounce(str_replace('user@example.com', 'other@example.com', $dsn), []);
|
||||
$this->assertSame('submitted', $message->refresh()->status);
|
||||
$content->recordBounce($dsn, []);
|
||||
$this->assertSame('bounced', $message->refresh()->status);
|
||||
}
|
||||
|
||||
public function test_incoming_plain_text_decodes_transfer_encoding(): void
|
||||
{
|
||||
$content = app(\App\Services\MailFlow\IncomingContent::class);
|
||||
$this->assertSame('你好', $content->decode(base64_encode('你好'), 3, [(object) ['attribute' => 'charset', 'value' => 'UTF-8']]));
|
||||
$this->assertSame('hello world', $content->decode('hello=20world', 4, []));
|
||||
}
|
||||
|
||||
public function test_disabling_feature_pauses_persisted_jobs(): void
|
||||
{
|
||||
$message = MailMessage::create(['message_key' => 'paused', 'purpose' => 'auto_reply', 'email' => 'user@example.com', 'subject' => 'Test', 'payload' => ['html' => 'Test']]);
|
||||
config(['mailflow.enabled' => false]);
|
||||
$this->conventional(0);
|
||||
(new \App\Jobs\SendMailMessageJob($message->id))->handle(app(Dispatcher::class));
|
||||
$this->assertSame('pending', $message->refresh()->status);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_settings_validation_enforces_free_caps_and_valid_folder(): void
|
||||
{
|
||||
$rules = (new \App\Http\Requests\Admin\ConfigSave())->rules();
|
||||
foreach ([['daily_limit' => 101], ['monthly_limit' => 3001], ['imap_folder' => 'INBOX}bad']] as $values) {
|
||||
$this->assertTrue(validator(['mail_flow' => $values], $rules)->fails());
|
||||
}
|
||||
$this->assertFalse(validator(['mail_flow' => ['daily_limit' => 100, 'monthly_limit' => 3000, 'imap_folder' => 'INBOX']], $rules)->fails());
|
||||
}
|
||||
|
||||
public function test_webhook_controller_verifies_raw_body_and_rejects_missing_signature(): void
|
||||
{
|
||||
$controller = new \App\Http\Controllers\V1\Guest\ResendController();
|
||||
$key = str_repeat('c', 32);
|
||||
config(['mailflow.resend_webhook_secret' => 'whsec_' . base64_encode($key)]);
|
||||
$body = json_encode(['type' => 'email.delivered', 'data' => ['email_id' => 'provider-id']]);
|
||||
$request = \Illuminate\Http\Request::create('/webhook', 'POST', [], [], [], [], $body);
|
||||
$time = (string) time();
|
||||
$request->headers->set('svix-id', 'test-id');
|
||||
$request->headers->set('svix-timestamp', $time);
|
||||
$request->headers->set('svix-signature', 'v1,' . base64_encode(hash_hmac('sha256', "test-id.$time.$body", $key, true)));
|
||||
$response = $controller->webhook($request, app(WebhookSignature::class), app(DeliveryEvents::class));
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
$this->assertSame(1, MailMessage::where('direction', 'event')->count());
|
||||
$request->headers->remove('svix-signature');
|
||||
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
|
||||
$controller->webhook($request, app(WebhookSignature::class), app(DeliveryEvents::class));
|
||||
}
|
||||
|
||||
public function test_mail_service_preserves_template_and_returns_no_credentials_even_if_log_insert_fails(): void
|
||||
{
|
||||
Schema::create('v2_mail_templates', function (Blueprint $t) { $t->id(); $t->string('name'); $t->string('subject'); $t->text('content'); });
|
||||
$this->conventional();
|
||||
// Deliberately no v2_mail_log table: the accepted message must not be retried because of logging.
|
||||
$result = \App\Services\MailService::sendEmail(['email' => 'user@example.com', 'subject' => 'Test',
|
||||
'template_name' => 'notify', 'purpose' => 'broadcast', 'message_key' => 'service-test',
|
||||
'template_value' => ['name' => 'Test', 'url' => 'https://example.com', 'content' => 'Hello']]);
|
||||
$this->assertSame('submitted', $result['status']);
|
||||
$this->assertFalse($result['retryable']);
|
||||
$this->assertArrayNotHasKey('config', $result);
|
||||
}
|
||||
|
||||
public function test_migration_can_roll_back_only_new_tables(): void
|
||||
{
|
||||
(require base_path('database/migrations/2026_10_09_170000_create_mail_flow_tables.php'))->down();
|
||||
$this->assertFalse(Schema::hasTable('v2_mail_messages'));
|
||||
$this->assertTrue(Schema::hasTable('v2_user'));
|
||||
$this->assertTrue(Schema::hasTable('v2_settings'));
|
||||
}
|
||||
public function test_admin_secret_save_is_encrypted_and_blank_preserves_existing_secret(): void
|
||||
{
|
||||
$request = Mockery::mock(\App\Http\Requests\Admin\ConfigSave::class);
|
||||
$request->shouldReceive('validated')->once()->andReturn(['mail_flow' => ['resend_key' => 'private-test-key', 'resend_enabled' => false]]);
|
||||
(new \App\Http\Controllers\V2\Admin\ConfigController())->save($request);
|
||||
$raw = DB::table('v2_settings')->where('name', 'mail_flow')->value('value');
|
||||
$this->assertStringNotContainsString('private-test-key', $raw);
|
||||
$this->assertSame('private-test-key', Settings::get('resend_key'));
|
||||
$request = Mockery::mock(\App\Http\Requests\Admin\ConfigSave::class);
|
||||
$request->shouldReceive('validated')->once()->andReturn(['mail_flow' => ['resend_key' => '']]);
|
||||
(new \App\Http\Controllers\V2\Admin\ConfigController())->save($request);
|
||||
$this->assertSame('private-test-key', Settings::get('resend_key'));
|
||||
$this->assertArrayNotHasKey('resend_key', Settings::publicSettings());
|
||||
}
|
||||
|
||||
public function test_registered_routes_and_scheduler_include_mail_flow_entrypoints(): void
|
||||
{
|
||||
$router = app('router');
|
||||
$this->assertNotNull($router->getRoutes()->match(\Illuminate\Http\Request::create('/api/v1/guest/resend/webhook', 'POST')));
|
||||
$plugins = Mockery::mock(\App\Services\Plugin\PluginManager::class);
|
||||
$plugins->shouldReceive('registerPluginSchedules')->once();
|
||||
$this->app->instance(\App\Services\Plugin\PluginManager::class, $plugins);
|
||||
$schedule = app(\Illuminate\Console\Scheduling\Schedule::class);
|
||||
$report = collect($schedule->events())->first(fn ($event) => str_contains($event->command ?? '', 'mail:report-usage') && $event->expression === '0 8 * * *');
|
||||
$this->assertNotNull($report);
|
||||
$this->assertSame('Asia/Shanghai', $report->timezone);
|
||||
}
|
||||
public function test_explicit_smtp_rejection_is_failure_not_unknown(): void
|
||||
{
|
||||
$transport = Mockery::mock(ConventionalTransport::class);
|
||||
$transport->shouldReceive('send')->once()->andThrow(new \Symfony\Component\Mailer\Exception\UnexpectedResponseException('Recipient rejected', 550));
|
||||
$this->app->instance(ConventionalTransport::class, $transport);
|
||||
$result = $this->send('smtp-rejected', 'broadcast');
|
||||
$this->assertSame('failed', $result['status']);
|
||||
$this->assertFalse($result['retryable']);
|
||||
}
|
||||
|
||||
public function test_temporary_smtp_rejection_retries_without_switching_to_resend(): void
|
||||
{
|
||||
$transport = Mockery::mock(ConventionalTransport::class);
|
||||
$transport->shouldReceive('send')->once()->andThrow(new \Symfony\Component\Mailer\Exception\UnexpectedResponseException('Temporary rejection', 451));
|
||||
$this->app->instance(ConventionalTransport::class, $transport);
|
||||
$result = $this->send('smtp-temporary', 'broadcast');
|
||||
$this->assertSame('pending', $result['status']);
|
||||
$this->assertTrue($result['retryable']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
public function test_report_counts_submission_time_instead_of_queue_creation_time(): void
|
||||
{
|
||||
$this->travelTo(\Carbon\Carbon::parse('2026-10-09 08:00:00', 'Asia/Shanghai'));
|
||||
MailMessage::create(['message_key' => 'queued-earlier', 'purpose' => 'broadcast', 'email' => 'user@example.com', 'subject' => 'Test',
|
||||
'channel' => 'conventional', 'status' => 'submitted', 'created_at' => now()->subDays(3), 'submitted_at' => now()->subMinute()]);
|
||||
$report = app(DailyReportService::class)->snapshot(now()->utc()->startOfDay());
|
||||
$this->assertSame(['out/conventional/submitted' => 1], $report->summary['counts']);
|
||||
}
|
||||
|
||||
public function test_resend_uses_site_display_name_and_preserves_inline_logo(): void
|
||||
{
|
||||
admin_setting(['app_name' => 'Test Site']);
|
||||
Http::fake(['*/usage' => Http::response($this->usage()), '*/emails' => Http::response(['id' => 'brand-email'])]);
|
||||
$attachment = ['filename' => 'logo.png', 'content' => 'encoded-image', 'content_id' => 'site-logo@dpl'];
|
||||
$this->send('brand', 'verification', ['attachments' => [$attachment]]);
|
||||
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/emails')
|
||||
&& $r['from'] === '"Test Site" <sender@example.com>' && $r['attachments'] === [$attachment]);
|
||||
}
|
||||
|
||||
public function test_footer_homepage_does_not_replace_login_action_link(): void
|
||||
{
|
||||
$html = view('mail.default.mailLogin', ['name' => 'Test Site', 'url' => 'https://actions.example',
|
||||
'link' => 'https://actions.example/login?token=example', 'site_url' => 'http://dpl.loc',
|
||||
'logo_src' => 'cid:site-logo@dpl'])->render();
|
||||
$this->assertStringContainsString('href="http://dpl.loc"', $html);
|
||||
$this->assertStringContainsString('href="https://actions.example/login?token=example"', $html);
|
||||
$this->assertStringContainsString('src="cid:site-logo@dpl"', $html);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user