18f9f7168SAndreas Gohr<?php 28f9f7168SAndreas Gohr 38f9f7168SAndreas Gohrnamespace dokuwiki\plugin\smtp\test; 48f9f7168SAndreas Gohr 58f9f7168SAndreas Gohruse DokuWikiTest; 68f9f7168SAndreas Gohruse dokuwiki\HTTP\DokuHTTPClient; 78f9f7168SAndreas Gohruse Mailer; 88f9f7168SAndreas Gohr 98f9f7168SAndreas Gohr/** 108f9f7168SAndreas Gohr * Full integration test for the smtp plugin 118f9f7168SAndreas Gohr * 128f9f7168SAndreas Gohr * This sends a real mail through DokuWiki's Mailer (which the plugin intercepts and 138f9f7168SAndreas Gohr * delivers over SMTP) to a running Mailpit server and then verifies through Mailpit's 148f9f7168SAndreas Gohr * HTTP API that the message was delivered correctly. 158f9f7168SAndreas Gohr * 168f9f7168SAndreas Gohr * The Mailpit server is configured through environment variables: 178f9f7168SAndreas Gohr * MAILPIT_HOST hostname of the Mailpit server (enables the test) 188f9f7168SAndreas Gohr * MAILPIT_SMTP_PORT the SMTP port to deliver to (default: 1025) 198f9f7168SAndreas Gohr * MAILPIT_HTTP_PORT the HTTP API/web interface port (default: 8025) 208f9f7168SAndreas Gohr * 218f9f7168SAndreas Gohr * When MAILPIT_HOST is not set the test is skipped, so it does not break the regular 228f9f7168SAndreas Gohr * test suite which runs without the Mailpit service. When it is set the server must be 238f9f7168SAndreas Gohr * reachable, otherwise the test fails. 248f9f7168SAndreas Gohr * 25*30b0aec1SAndreas Gohr * The STARTTLS test (smtp_allow_insecure) uses the same server, so that Mailpit must be 26*30b0aec1SAndreas Gohr * started with --smtp-tls-cert/--smtp-tls-key to offer STARTTLS (a self-signed 27*30b0aec1SAndreas Gohr * certificate is exactly what this test wants to exercise); otherwise it fails. 28*30b0aec1SAndreas Gohr * 298f9f7168SAndreas Gohr * @group plugin_smtp 308f9f7168SAndreas Gohr * @group plugins 318f9f7168SAndreas Gohr */ 328f9f7168SAndreas Gohrclass IntegrationTest extends DokuWikiTest 338f9f7168SAndreas Gohr{ 348f9f7168SAndreas Gohr protected $pluginsEnabled = ['smtp']; 358f9f7168SAndreas Gohr 368f9f7168SAndreas Gohr /** @var string hostname of the Mailpit server */ 378f9f7168SAndreas Gohr protected $host; 388f9f7168SAndreas Gohr 398f9f7168SAndreas Gohr /** @var int SMTP port to deliver to */ 408f9f7168SAndreas Gohr protected $smtpPort; 418f9f7168SAndreas Gohr 428f9f7168SAndreas Gohr /** @var string base URL of the Mailpit HTTP API, without trailing slash */ 438f9f7168SAndreas Gohr protected $apiBase; 448f9f7168SAndreas Gohr 458f9f7168SAndreas Gohr /** @inheritdoc */ 468f9f7168SAndreas Gohr public function setUp(): void 478f9f7168SAndreas Gohr { 488f9f7168SAndreas Gohr parent::setUp(); 498f9f7168SAndreas Gohr 508f9f7168SAndreas Gohr // the test only runs when a Mailpit server is configured via MAILPIT_HOST, 518f9f7168SAndreas Gohr // otherwise it is skipped (eg. regular CI/local runs without Mailpit) 528f9f7168SAndreas Gohr $this->host = getenv('MAILPIT_HOST'); 538f9f7168SAndreas Gohr if ($this->host === false || $this->host === '') { 548f9f7168SAndreas Gohr $this->markTestSkipped('MAILPIT_HOST is not set, skipping the Mailpit integration test'); 558f9f7168SAndreas Gohr } 568f9f7168SAndreas Gohr 578f9f7168SAndreas Gohr $this->smtpPort = (int)(getenv('MAILPIT_SMTP_PORT') ?: 1025); 588f9f7168SAndreas Gohr $httpPort = (int)(getenv('MAILPIT_HTTP_PORT') ?: 8025); 598f9f7168SAndreas Gohr $this->apiBase = 'http://' . $this->host . ':' . $httpPort; 608f9f7168SAndreas Gohr 618f9f7168SAndreas Gohr // a Mailpit server was requested, so it must actually be reachable 628f9f7168SAndreas Gohr $sock = @fsockopen($this->host, $this->smtpPort, $errno, $errstr, 2); 638f9f7168SAndreas Gohr if (!$sock) { 648f9f7168SAndreas Gohr $this->fail("No Mailpit SMTP server reachable at $this->host:$this->smtpPort ($errstr)"); 658f9f7168SAndreas Gohr } 668f9f7168SAndreas Gohr fclose($sock); 678f9f7168SAndreas Gohr 688f9f7168SAndreas Gohr // point the plugin at our Mailpit server 698f9f7168SAndreas Gohr global $conf; 708f9f7168SAndreas Gohr $conf['plugin']['smtp']['smtp_host'] = $this->host; 718f9f7168SAndreas Gohr $conf['plugin']['smtp']['smtp_port'] = $this->smtpPort; 728f9f7168SAndreas Gohr $conf['plugin']['smtp']['smtp_ssl'] = ''; 738f9f7168SAndreas Gohr $conf['plugin']['smtp']['auth_user'] = ''; 748f9f7168SAndreas Gohr $conf['plugin']['smtp']['auth_pass'] = ''; 758f9f7168SAndreas Gohr $conf['plugin']['smtp']['localdomain'] = 'test.localhost'; 768f9f7168SAndreas Gohr $conf['plugin']['smtp']['debug'] = 0; 778f9f7168SAndreas Gohr 788f9f7168SAndreas Gohr // give DokuWiki's Mailer a sane default sender 798f9f7168SAndreas Gohr $conf['mailfrom'] = 'wiki@example.com'; 808f9f7168SAndreas Gohr } 818f9f7168SAndreas Gohr 828f9f7168SAndreas Gohr /** 838f9f7168SAndreas Gohr * A mail sent through DokuWiki's Mailer must be delivered to Mailpit over SMTP. 848f9f7168SAndreas Gohr * 858f9f7168SAndreas Gohr * To and Cc come from the message headers, while the Bcc recipient is delivered 868f9f7168SAndreas Gohr * through the SMTP envelope even though the plugin strips the Bcc header from the 878f9f7168SAndreas Gohr * message body. 888f9f7168SAndreas Gohr */ 898f9f7168SAndreas Gohr public function testSendMail(): void 908f9f7168SAndreas Gohr { 918f9f7168SAndreas Gohr $subject = 'SMTP plugin integration test ' . uniqid('', true); 928f9f7168SAndreas Gohr $bodyText = 'Hello from the DokuWiki smtp plugin integration test.'; 938f9f7168SAndreas Gohr 948f9f7168SAndreas Gohr $mailer = new Mailer(); 958f9f7168SAndreas Gohr $mailer->from('Wiki Admin <wiki@example.com>'); 968f9f7168SAndreas Gohr $mailer->to('Jane Doe <jane@example.com>'); 978f9f7168SAndreas Gohr $mailer->cc('carol@example.com'); 988f9f7168SAndreas Gohr $mailer->bcc('secret@example.com'); 998f9f7168SAndreas Gohr $mailer->subject($subject); 1008f9f7168SAndreas Gohr $mailer->setBody($bodyText); 1018f9f7168SAndreas Gohr 1028f9f7168SAndreas Gohr $ok = $mailer->send(); 1038f9f7168SAndreas Gohr $this->assertTrue($ok, 'Mailer::send() should report success when talking to Mailpit'); 1048f9f7168SAndreas Gohr 1058f9f7168SAndreas Gohr // the message should now be available through the Mailpit API 1068f9f7168SAndreas Gohr $message = $this->findMessageBySubject($subject); 1078f9f7168SAndreas Gohr $this->assertNotNull($message, 'The sent message should show up in Mailpit'); 1088f9f7168SAndreas Gohr 1098f9f7168SAndreas Gohr // sender and header recipients 1108f9f7168SAndreas Gohr $this->assertEquals('wiki@example.com', $message['From']['Address']); 1118f9f7168SAndreas Gohr $this->assertEquals(['jane@example.com'], $this->addresses($message['To'])); 1128f9f7168SAndreas Gohr $this->assertEquals(['carol@example.com'], $this->addresses($message['Cc'])); 1138f9f7168SAndreas Gohr $this->assertEquals(['secret@example.com'], $this->addresses($message['Bcc'])); 1148f9f7168SAndreas Gohr 1158f9f7168SAndreas Gohr // the body must have arrived intact 1168f9f7168SAndreas Gohr $full = $this->apiGet('/api/v1/message/' . rawurlencode($message['ID'])); 1178f9f7168SAndreas Gohr $this->assertStringContainsString($bodyText, $full['Text']); 1188f9f7168SAndreas Gohr } 1198f9f7168SAndreas Gohr 1208f9f7168SAndreas Gohr /** 121*30b0aec1SAndreas Gohr * The smtp_allow_insecure option must control whether an untrusted STARTTLS 122*30b0aec1SAndreas Gohr * certificate is accepted (issue #32). 123*30b0aec1SAndreas Gohr * 124*30b0aec1SAndreas Gohr * Against a Mailpit offering STARTTLS with a self-signed certificate, sending must 125*30b0aec1SAndreas Gohr * fail while certificate verification is on (smtp_allow_insecure off) and succeed 126*30b0aec1SAndreas Gohr * once verification is disabled (smtp_allow_insecure on). 127*30b0aec1SAndreas Gohr */ 128*30b0aec1SAndreas Gohr public function testSendMailTlsAllowsInsecureCert(): void 129*30b0aec1SAndreas Gohr { 130*30b0aec1SAndreas Gohr // the same server and HTTP API as the plain test, just talked to over STARTTLS 131*30b0aec1SAndreas Gohr global $conf; 132*30b0aec1SAndreas Gohr $conf['plugin']['smtp']['smtp_ssl'] = 'tls'; 133*30b0aec1SAndreas Gohr 134*30b0aec1SAndreas Gohr // with certificate verification enabled the self-signed cert is rejected 135*30b0aec1SAndreas Gohr $conf['plugin']['smtp']['smtp_allow_insecure'] = 0; 136*30b0aec1SAndreas Gohr $rejectSubject = 'SMTP plugin TLS reject test ' . uniqid('', true); 137*30b0aec1SAndreas Gohr $this->assertFalse( 138*30b0aec1SAndreas Gohr $this->sendProbe($rejectSubject), 139*30b0aec1SAndreas Gohr 'Sending over STARTTLS with an untrusted certificate must fail when smtp_allow_insecure is off' 140*30b0aec1SAndreas Gohr ); 141*30b0aec1SAndreas Gohr $this->assertNull( 142*30b0aec1SAndreas Gohr $this->findMessageBySubject($rejectSubject), 143*30b0aec1SAndreas Gohr 'A message rejected during STARTTLS must not be delivered' 144*30b0aec1SAndreas Gohr ); 145*30b0aec1SAndreas Gohr 146*30b0aec1SAndreas Gohr // disabling verification accepts the self-signed cert and delivers the mail 147*30b0aec1SAndreas Gohr $conf['plugin']['smtp']['smtp_allow_insecure'] = 1; 148*30b0aec1SAndreas Gohr $acceptSubject = 'SMTP plugin TLS insecure test ' . uniqid('', true); 149*30b0aec1SAndreas Gohr $this->assertTrue( 150*30b0aec1SAndreas Gohr $this->sendProbe($acceptSubject), 151*30b0aec1SAndreas Gohr 'Sending over STARTTLS with a self-signed certificate must succeed when smtp_allow_insecure is on' 152*30b0aec1SAndreas Gohr ); 153*30b0aec1SAndreas Gohr $this->assertNotNull( 154*30b0aec1SAndreas Gohr $this->findMessageBySubject($acceptSubject), 155*30b0aec1SAndreas Gohr 'The mail sent over insecure STARTTLS should show up in Mailpit' 156*30b0aec1SAndreas Gohr ); 157*30b0aec1SAndreas Gohr } 158*30b0aec1SAndreas Gohr 159*30b0aec1SAndreas Gohr /** 160*30b0aec1SAndreas Gohr * Send a minimal mail through DokuWiki's Mailer and report whether it succeeded 161*30b0aec1SAndreas Gohr * 162*30b0aec1SAndreas Gohr * An untrusted certificate makes the mailer library emit a PHP SSL warning deep in 163*30b0aec1SAndreas Gohr * the call stack; it is swallowed here so the test can observe the boolean send 164*30b0aec1SAndreas Gohr * result rather than abort on the warning. 165*30b0aec1SAndreas Gohr * 166*30b0aec1SAndreas Gohr * @param string $subject unique subject for the probe mail 167*30b0aec1SAndreas Gohr * @return bool whether the mail was sent successfully 168*30b0aec1SAndreas Gohr */ 169*30b0aec1SAndreas Gohr protected function sendProbe(string $subject): bool 170*30b0aec1SAndreas Gohr { 171*30b0aec1SAndreas Gohr $mailer = new Mailer(); 172*30b0aec1SAndreas Gohr $mailer->from('Wiki Admin <wiki@example.com>'); 173*30b0aec1SAndreas Gohr $mailer->to('Jane Doe <jane@example.com>'); 174*30b0aec1SAndreas Gohr $mailer->subject($subject); 175*30b0aec1SAndreas Gohr $mailer->setBody('STARTTLS integration probe.'); 176*30b0aec1SAndreas Gohr 177*30b0aec1SAndreas Gohr set_error_handler(static fn() => true); 178*30b0aec1SAndreas Gohr try { 179*30b0aec1SAndreas Gohr return (bool)$mailer->send(); 180*30b0aec1SAndreas Gohr } finally { 181*30b0aec1SAndreas Gohr restore_error_handler(); 182*30b0aec1SAndreas Gohr } 183*30b0aec1SAndreas Gohr } 184*30b0aec1SAndreas Gohr 185*30b0aec1SAndreas Gohr /** 1868f9f7168SAndreas Gohr * Reduce a Mailpit address list to a plain list of email addresses 1878f9f7168SAndreas Gohr * 1888f9f7168SAndreas Gohr * @param array $list list of {Name, Address} entries as returned by Mailpit 1898f9f7168SAndreas Gohr * @return string[] 1908f9f7168SAndreas Gohr */ 1918f9f7168SAndreas Gohr protected function addresses(array $list): array 1928f9f7168SAndreas Gohr { 1938f9f7168SAndreas Gohr return array_map(static fn($addr) => $addr['Address'], $list); 1948f9f7168SAndreas Gohr } 1958f9f7168SAndreas Gohr 1968f9f7168SAndreas Gohr /** 1978f9f7168SAndreas Gohr * Find a message in Mailpit whose subject contains the given needle 1988f9f7168SAndreas Gohr * 1998f9f7168SAndreas Gohr * DokuWiki prefixes the subject with the wiki title (eg. "[My Wiki] ..."), so we 2008f9f7168SAndreas Gohr * match on a unique substring rather than the full subject. Mailpit stores the 2018f9f7168SAndreas Gohr * message before acknowledging the SMTP DATA command, so it is available as soon 2028f9f7168SAndreas Gohr * as Mailer::send() returns - no polling needed. 2038f9f7168SAndreas Gohr * 2048f9f7168SAndreas Gohr * @param string $needle a unique substring of the subject 2058f9f7168SAndreas Gohr * @return array|null the message stub from the listing or null if not found 2068f9f7168SAndreas Gohr */ 2078f9f7168SAndreas Gohr protected function findMessageBySubject($needle) 2088f9f7168SAndreas Gohr { 2098f9f7168SAndreas Gohr $list = $this->apiGet('/api/v1/messages'); 2108f9f7168SAndreas Gohr foreach ($list['messages'] as $msg) { 2118f9f7168SAndreas Gohr if (strpos($msg['Subject'], $needle) !== false) return $msg; 2128f9f7168SAndreas Gohr } 2138f9f7168SAndreas Gohr return null; 2148f9f7168SAndreas Gohr } 2158f9f7168SAndreas Gohr 2168f9f7168SAndreas Gohr /** 2178f9f7168SAndreas Gohr * Perform a GET request against the Mailpit API and return the decoded JSON 2188f9f7168SAndreas Gohr * 2198f9f7168SAndreas Gohr * @param string $path API path including the leading slash 2208f9f7168SAndreas Gohr * @return array 2218f9f7168SAndreas Gohr */ 2228f9f7168SAndreas Gohr protected function apiGet($path) 2238f9f7168SAndreas Gohr { 2248f9f7168SAndreas Gohr $client = new DokuHTTPClient(); 2258f9f7168SAndreas Gohr $body = $client->get($this->apiBase . $path); 2268f9f7168SAndreas Gohr $this->assertNotFalse($body, 'Mailpit API request to ' . $path . ' failed: ' . $client->error); 2278f9f7168SAndreas Gohr return json_decode($body, true); 2288f9f7168SAndreas Gohr } 2298f9f7168SAndreas Gohr} 230