dpl/tests/Feature/MailFlow/MailFlowTest.php

495 lines
29 KiB
PHP

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