true, 'verboseOutput' => true ]); $test->describe('メーラー(偽物のSMTPサーバー)', function($test): void { $log = tempnam(sys_get_temp_dir(), 'lbsmtp_'); $srv = MockServer::start('smtp-server.php', [$log]); if ($srv === null) { $test->skipAll('偽物のサーバーを起動出来ない(proc_openが無効?)'); } $info = fn() => ['host' => '127.0.0.1', 'port' => $srv->port, 'user' => 'me@076.moe', 'pass' => 'ひみつ', 'from' => 'me@076.moe']; $send = function(callable $fn) use ($info, $log): string { file_put_contents($log, ''); $mail = new Mailer($info()); $mail->connect(); try { $mail->authenticate(); $fn($mail); } finally { $mail->disconnect(); } return (string)file_get_contents($log); }; $test->it('MAILER_ENABLEDが無効なら設定なしで作れないはず', function($test): void { if (MAILER_ENABLED) { $test->assertNotNull(new Mailer()); return; } $test->assertThrows(fn() => new Mailer(), \Exception::class); }); $test->it('認証してメールを送れるはず', function($test) use ($send): void { $out = $send(fn($m) => $m->send('you@example.com', 'テスト件名', "こんにちは\n2行目", 'あなた')); $test->assertStringContains("C: AUTH LOGIN\r\n", $out); $test->assertStringContains('C: '.base64_encode('me@076.moe')."\r\n", $out); $test->assertStringContains('C: '.base64_encode('ひみつ')."\r\n", $out); $test->assertStringContains("C: MAIL FROM: \r\n", $out); $test->assertStringContains("C: RCPT TO: \r\n", $out); $test->assertStringContains("C: QUIT\r\n", $out); $test->assertStringContains('Subject: '.mb_encode_mimeheader('テスト件名', 'UTF-8', 'Q'), $out); $test->assertStringContains('To: '.mb_encode_mimeheader('あなた', 'UTF-8', 'Q').' ', $out); $test->assertTrue((bool)preg_match('/^Message-ID: <[0-9a-f]{32}@076\.moe>\r$/m', $out), 'Message-IDが無い'); $test->assertStringContains('Content-Transfer-Encoding: quoted-printable', $out); }); $test->it('EHLOでは自分のホスト名を名乗るはず', function($test) use ($send): void { $out = $send(fn($m) => $m->send('you@example.com', 'x', 'x')); $test->assertStringContains('C: EHLO '.(gethostname() ?: 'localhost')."\r\n", $out); $test->assertFalse(str_contains($out, 'C: EHLO 127.0.0.1')); }); $test->it('本文をquoted-printableでエンコードし、改行をCRLFに揃えるはず', function($test) use ($send): void { $out = $send(fn($m) => $m->send('you@example.com', 'x', "日本語\n二行目")); preg_match("/\r\n\r\n(.*)END DATA/s", $out, $m); $body = quoted_printable_decode($m[1] ?? ''); $test->assertEquals("日本語\r\n二行目\r\n", $body); }); $test->it('「.」で始まる行をドットスタッフィングするはず', function($test) use ($send): void { // 以前は「.」だけの行で本文が途中で終わった $out = $send(fn($m) => $m->send('you@example.com', 'x', "前\n.\n.後ろ\n終わり")); $test->assertStringContains("\r\n..\r\n..=E5=BE=8C", $out); $test->assertStringContains('終わり', quoted_printable_decode($out)); }); $test->it('ヘッダー注入を拒否するはず', function($test) use ($info): void { $mail = new Mailer($info()); $mail->connect(); try { $test->assertThrows(fn() => $mail->send("you@example.com>\r\nRCPT TO: assertThrows(fn() => $mail->send('you@example.com', "件名\r\nBcc: evil@example.com", 'x'), \InvalidArgumentException::class); $test->assertThrows(fn() => $mail->send('you@example.com', 'x', 'x', "名前\nBcc: x"), \InvalidArgumentException::class); $test->assertThrows(fn() => $mail->send('not-an-address', 'x', 'x'), \InvalidArgumentException::class); } finally { $mail->disconnect(); } }); $test->it('拒否された宛先は例外になるはず', function($test) use ($send): void { $test->assertThrows(fn() => $send(fn($m) => $m->send('reject@example.com', 'x', 'x')), \Exception::class); }); $test->it('接続出来ない場合は例外になるはず', function($test): void { $probe = stream_socket_server('tcp://127.0.0.1:0'); $port = (int)substr(strrchr(stream_socket_get_name($probe, false), ':'), 1); fclose($probe); $mail = new Mailer(['host' => '127.0.0.1', 'port' => $port, 'from' => 'me@076.moe']); $test->assertThrows(fn() => $mail->connect(), \Exception::class); }); $srv?->stop(); @unlink($log); }); $test->describe('メーラー(STARTTLS、PHP純正のTLS 1.3)', function($test): void { $cert = TestCert::get('ec'); $log = tempnam(sys_get_temp_dir(), 'lbsmtp_'); $srv = MockServer::start('smtp-server.php', [$log, $cert['cert'], $cert['key']]); if ($srv === null) { $test->skipAll('偽物のサーバーを起動出来ない(proc_openが無効?)'); return; } $info = fn(array $tls) => ['host' => 'localhost', 'port' => $srv->port, 'user' => 'me@076.moe', 'pass' => 'ひみつ', 'from' => 'me@076.moe', 'tls' => $tls]; $test->it('STARTTLSの後に認証して送れるはず', function($test) use ($info, $log, $cert): void { file_put_contents($log, ''); $mail = new Mailer($info(['cafile' => $cert['ca']])); $mail->connect(); try { $mail->authenticate(); $mail->send('you@example.com', '暗号化', '本文'); } finally { $mail->disconnect(); } $out = (string)file_get_contents($log); $test->assertStringContains("C: STARTTLS\r\n", $out); $test->assertStringContains('TLS: TLS_', $out); // パスワードはTLSの後に送る $test->assertTrue(strpos($out, 'TLS: TLS_') < strpos($out, 'C: '.base64_encode('ひみつ'))); $test->assertStringContains("C: QUIT\r\n", $out); }); $test->it('信頼出来ない証明書なら認証せずに例外になるはず', function($test) use ($info, $log): void { file_put_contents($log, ''); $mail = new Mailer($info(['cafile' => TestCert::get('self')['cert']])); $mail->connect(); try { $test->assertThrows(fn() => $mail->authenticate(), \Exception::class); } finally { $mail->disconnect(); } $test->assertFalse(str_contains((string)file_get_contents($log), base64_encode('ひみつ'))); }); $srv->stop(); @unlink($log); }); $test->printSummary();