feat(mail): add Resend delivery, inbox replies and notification tiers

This commit is contained in:
hellcat 2026-10-10 18:53:35 +08:00
parent 208536e1cb
commit ad42a0e1e7
53 changed files with 2626 additions and 43 deletions

1
.gitignore vendored
View File

@ -47,3 +47,4 @@ compose.split.yaml
/macos-client/.build/
/macos-client/.swiftpm/
/storage/app/private/

View 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();
}
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@ -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;

View File

@ -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)

View 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]);
}
}

View File

@ -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,

View File

@ -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;

View 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() : [],
]);
}
}

View File

@ -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}}'),

View File

@ -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'));
}

View File

@ -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

View File

@ -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

View File

@ -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']);

View File

@ -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)]);
}
}
}

View 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]);
}
}
}

View 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'];
}

View 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',
];
}

View 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'];
}

View File

@ -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' => [],

View File

@ -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,

View 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;
}
}

View 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];
}
}

View 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']];
}
}

View 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(),
];
}
}

View 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();
}
}
}

View 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,
};
}
}

View 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];
}
}

View 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;
}
}

View 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;
}
}

View 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']);
}
}

View 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`')]);
}
}

View 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();
}
}

View 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;
}
}

View 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;
}
}

View File

@ -8,9 +8,7 @@ use App\Models\MailTemplate;
use App\Models\User;
use App\Utils\CacheKey;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class MailService
{
@ -165,6 +163,7 @@ class MailService
public function remindTraffic(User $user)
{
if (\App\Services\MailFlow\AutomaticMailPolicy::decision('traffic_reminder')) { return; }
if (!$user->remind_traffic)
return;
if (!$this->remindTrafficIsWarnValue($user->u, $user->d, $user->transfer_enable))
@ -181,6 +180,8 @@ class MailService
'app_name' => admin_setting('app_name', 'XBoard')
]),
'template_name' => 'remindTraffic',
'purpose' => 'traffic_reminder',
'user_id' => $user->id,
'template_value' => [
'name' => admin_setting('app_name', 'XBoard'),
'url' => admin_setting('app_url')
@ -190,6 +191,7 @@ class MailService
public function remindExpire(User $user)
{
if (\App\Services\MailFlow\AutomaticMailPolicy::decision('expiry_reminder')) { return; }
if (!$this->shouldSendExpireRemind($user)) {
return;
}
@ -200,6 +202,8 @@ class MailService
'app_name' => admin_setting('app_name', 'XBoard')
]),
'template_name' => 'remindExpire',
'purpose' => 'expiry_reminder',
'user_id' => $user->id,
'template_value' => [
'name' => admin_setting('app_name', 'XBoard'),
'url' => admin_setting('app_url')
@ -239,20 +243,16 @@ class MailService
*/
public static function sendEmail(array $params)
{
if (admin_setting('email_host')) {
Config::set('mail.host', admin_setting('email_host', config('mail.host')));
Config::set('mail.port', admin_setting('email_port', config('mail.port')));
Config::set('mail.encryption', admin_setting('email_encryption', config('mail.encryption')));
Config::set('mail.username', admin_setting('email_username', config('mail.username')));
Config::set('mail.password', admin_setting('email_password', config('mail.password')));
Config::set('mail.from.address', admin_setting('email_from_address', config('mail.from.address')));
Config::set('mail.from.name', admin_setting('app_name', 'XBoard'));
}
$params['purpose'] = \App\Services\MailFlow\AutomaticMailPolicy::purpose($params);
$email = $params['email'];
$subject = $params['subject'];
$templateName = $params['template_name'];
$templateValue = $params['template_value'] ?? [];
// Website branding is independent of the mailbox domain and action links.
if (is_array($templateValue)) {
$templateValue = array_replace($templateValue, \App\Services\MailFlow\Branding::values());
}
$vars = is_array($templateValue) ? ($templateValue['vars'] ?? []) : [];
$contentMode = is_array($templateValue) ? ($templateValue['content_mode'] ?? null) : null;
@ -288,34 +288,34 @@ class MailService
$renderedContent = self::renderPlaceholders($dbTemplate->content, $renderVars);
$subject = $renderedSubject ?: $subject;
Mail::html($renderedContent, function ($message) use ($email, $subject) {
$message->to($email)->subject($subject);
});
$html = $renderedContent;
$params['template_name'] = 'db:' . $templateName;
} else {
$params['template_name'] = 'mail.default.' . $templateName;
Mail::send(
$params['template_name'],
$params['template_value'],
function ($message) use ($email, $subject) {
$message->to($email)->subject($subject);
}
);
$html = view($params['template_name'], $params['template_value'])->render();
}
$error = null;
} catch (\Exception $e) {
Log::error($e);
$error = $e->getMessage();
if (str_contains($html, 'cid:site-logo@dpl')) {
$params['attachments'] = \App\Services\MailFlow\Branding::attachments();
}
$result = app(\App\Services\MailFlow\Dispatcher::class)->send($params, $subject, $html);
} catch (\Throwable $e) {
// No provider response, template values or credentials in application logs.
$result = ['status' => 'failed', 'error' => 'mail_processing_error', 'retryable' => false];
Log::warning('Mail processing failed', ['exception_type' => get_class($e)]);
}
$log = [
'email' => $params['email'],
'subject' => $params['subject'],
'template_name' => $params['template_name'],
'error' => $error,
'config' => config('mail')
'error' => $result['error'] ?? null
];
MailLog::create($log);
return $log;
try {
MailLog::create($log);
} catch (\Throwable $e) {
// A log failure after submission must not trigger another email.
Log::warning('Mail log persistence failed', ['exception_type' => get_class($e)]);
}
return $log + $result;
}
/**

View File

@ -49,7 +49,11 @@ class TicketService
throw new ApiException('工单回复失败');
}
HookManager::call('ticket.reply.admin.after', [$ticket, $ticketMessage]);
$this->sendEmailNotify($ticket, $ticketMessage);
try {
$this->sendEmailNotify($ticket, $ticketMessage);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('Ticket email notification could not be queued', ['ticket_id' => $ticket->id]);
}
}
public function createTicket($userId, $subject, $level, $message)
@ -91,6 +95,9 @@ class TicketService
private function sendEmailNotify(Ticket $ticket, TicketMessage $ticketMessage)
{
$user = User::find($ticket->user_id);
if (!$user || !filter_var($user->email, FILTER_VALIDATE_EMAIL)) {
return;
}
$cacheKey = 'ticket_sendEmailNotify_' . $ticket->user_id;
if (!Cache::get($cacheKey)) {
Cache::put($cacheKey, 1, 1800);
@ -98,6 +105,8 @@ class TicketService
'email' => $user->email,
'subject' => '您在' . admin_setting('app_name', 'XBoard') . '的工单得到了回复',
'template_name' => 'notify',
'purpose' => 'ticket_reply',
'user_id' => $user->id,
'template_value' => [
'name' => admin_setting('app_name', 'XBoard'),
'url' => admin_setting('app_url'),

26
config/mailflow.php Normal file
View 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'),
];

View File

@ -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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

80
resources/js/admin/mail-flow.js vendored Normal file
View 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;}
});

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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;">&nbsp;</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>

View File

@ -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>

View 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);
}
}

View 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);
}
}