1<?php 2 3namespace dokuwiki\plugin\smtp\test; 4 5use DokuWikiTest; 6use dokuwiki\HTTP\DokuHTTPClient; 7use Mailer; 8 9/** 10 * Full integration test for the smtp plugin 11 * 12 * This sends a real mail through DokuWiki's Mailer (which the plugin intercepts and 13 * delivers over SMTP) to a running Mailpit server and then verifies through Mailpit's 14 * HTTP API that the message was delivered correctly. 15 * 16 * The Mailpit server is configured through environment variables: 17 * MAILPIT_HOST hostname of the Mailpit server (enables the test) 18 * MAILPIT_SMTP_PORT the SMTP port to deliver to (default: 1025) 19 * MAILPIT_HTTP_PORT the HTTP API/web interface port (default: 8025) 20 * 21 * When MAILPIT_HOST is not set the test is skipped, so it does not break the regular 22 * test suite which runs without the Mailpit service. When it is set the server must be 23 * reachable, otherwise the test fails. 24 * 25 * The STARTTLS test (smtp_allow_insecure) uses the same server, so that Mailpit must be 26 * started with --smtp-tls-cert/--smtp-tls-key to offer STARTTLS (a self-signed 27 * certificate is exactly what this test wants to exercise); otherwise it fails. 28 * 29 * @group plugin_smtp 30 * @group plugins 31 */ 32class IntegrationTest extends DokuWikiTest 33{ 34 protected $pluginsEnabled = ['smtp']; 35 36 /** @var string hostname of the Mailpit server */ 37 protected $host; 38 39 /** @var int SMTP port to deliver to */ 40 protected $smtpPort; 41 42 /** @var string base URL of the Mailpit HTTP API, without trailing slash */ 43 protected $apiBase; 44 45 /** @inheritdoc */ 46 public function setUp(): void 47 { 48 parent::setUp(); 49 50 // the test only runs when a Mailpit server is configured via MAILPIT_HOST, 51 // otherwise it is skipped (eg. regular CI/local runs without Mailpit) 52 $this->host = getenv('MAILPIT_HOST'); 53 if ($this->host === false || $this->host === '') { 54 $this->markTestSkipped('MAILPIT_HOST is not set, skipping the Mailpit integration test'); 55 } 56 57 $this->smtpPort = (int)(getenv('MAILPIT_SMTP_PORT') ?: 1025); 58 $httpPort = (int)(getenv('MAILPIT_HTTP_PORT') ?: 8025); 59 $this->apiBase = 'http://' . $this->host . ':' . $httpPort; 60 61 // a Mailpit server was requested, so it must actually be reachable 62 $sock = @fsockopen($this->host, $this->smtpPort, $errno, $errstr, 2); 63 if (!$sock) { 64 $this->fail("No Mailpit SMTP server reachable at $this->host:$this->smtpPort ($errstr)"); 65 } 66 fclose($sock); 67 68 // point the plugin at our Mailpit server 69 global $conf; 70 $conf['plugin']['smtp']['smtp_host'] = $this->host; 71 $conf['plugin']['smtp']['smtp_port'] = $this->smtpPort; 72 $conf['plugin']['smtp']['smtp_ssl'] = ''; 73 $conf['plugin']['smtp']['auth_user'] = ''; 74 $conf['plugin']['smtp']['auth_pass'] = ''; 75 $conf['plugin']['smtp']['localdomain'] = 'test.localhost'; 76 $conf['plugin']['smtp']['debug'] = 0; 77 78 // give DokuWiki's Mailer a sane default sender 79 $conf['mailfrom'] = 'wiki@example.com'; 80 } 81 82 /** 83 * A mail sent through DokuWiki's Mailer must be delivered to Mailpit over SMTP. 84 * 85 * To and Cc come from the message headers, while the Bcc recipient is delivered 86 * through the SMTP envelope even though the plugin strips the Bcc header from the 87 * message body. 88 */ 89 public function testSendMail(): void 90 { 91 $subject = 'SMTP plugin integration test ' . uniqid('', true); 92 $bodyText = 'Hello from the DokuWiki smtp plugin integration test.'; 93 94 $mailer = new Mailer(); 95 $mailer->from('Wiki Admin <wiki@example.com>'); 96 $mailer->to('Jane Doe <jane@example.com>'); 97 $mailer->cc('carol@example.com'); 98 $mailer->bcc('secret@example.com'); 99 $mailer->subject($subject); 100 $mailer->setBody($bodyText); 101 102 $ok = $mailer->send(); 103 $this->assertTrue($ok, 'Mailer::send() should report success when talking to Mailpit'); 104 105 // the message should now be available through the Mailpit API 106 $message = $this->findMessageBySubject($subject); 107 $this->assertNotNull($message, 'The sent message should show up in Mailpit'); 108 109 // sender and header recipients 110 $this->assertEquals('wiki@example.com', $message['From']['Address']); 111 $this->assertEquals(['jane@example.com'], $this->addresses($message['To'])); 112 $this->assertEquals(['carol@example.com'], $this->addresses($message['Cc'])); 113 $this->assertEquals(['secret@example.com'], $this->addresses($message['Bcc'])); 114 115 // the body must have arrived intact 116 $full = $this->apiGet('/api/v1/message/' . rawurlencode($message['ID'])); 117 $this->assertStringContainsString($bodyText, $full['Text']); 118 } 119 120 /** 121 * The smtp_allow_insecure option must control whether an untrusted STARTTLS 122 * certificate is accepted (issue #32). 123 * 124 * Against a Mailpit offering STARTTLS with a self-signed certificate, sending must 125 * fail while certificate verification is on (smtp_allow_insecure off) and succeed 126 * once verification is disabled (smtp_allow_insecure on). 127 */ 128 public function testSendMailTlsAllowsInsecureCert(): void 129 { 130 // the same server and HTTP API as the plain test, just talked to over STARTTLS 131 global $conf; 132 $conf['plugin']['smtp']['smtp_ssl'] = 'tls'; 133 134 // with certificate verification enabled the self-signed cert is rejected 135 $conf['plugin']['smtp']['smtp_allow_insecure'] = 0; 136 $rejectSubject = 'SMTP plugin TLS reject test ' . uniqid('', true); 137 $this->assertFalse( 138 $this->sendProbe($rejectSubject), 139 'Sending over STARTTLS with an untrusted certificate must fail when smtp_allow_insecure is off' 140 ); 141 $this->assertNull( 142 $this->findMessageBySubject($rejectSubject), 143 'A message rejected during STARTTLS must not be delivered' 144 ); 145 146 // disabling verification accepts the self-signed cert and delivers the mail 147 $conf['plugin']['smtp']['smtp_allow_insecure'] = 1; 148 $acceptSubject = 'SMTP plugin TLS insecure test ' . uniqid('', true); 149 $this->assertTrue( 150 $this->sendProbe($acceptSubject), 151 'Sending over STARTTLS with a self-signed certificate must succeed when smtp_allow_insecure is on' 152 ); 153 $this->assertNotNull( 154 $this->findMessageBySubject($acceptSubject), 155 'The mail sent over insecure STARTTLS should show up in Mailpit' 156 ); 157 } 158 159 /** 160 * Send a minimal mail through DokuWiki's Mailer and report whether it succeeded 161 * 162 * An untrusted certificate makes the mailer library emit a PHP SSL warning deep in 163 * the call stack; it is swallowed here so the test can observe the boolean send 164 * result rather than abort on the warning. 165 * 166 * @param string $subject unique subject for the probe mail 167 * @return bool whether the mail was sent successfully 168 */ 169 protected function sendProbe(string $subject): bool 170 { 171 $mailer = new Mailer(); 172 $mailer->from('Wiki Admin <wiki@example.com>'); 173 $mailer->to('Jane Doe <jane@example.com>'); 174 $mailer->subject($subject); 175 $mailer->setBody('STARTTLS integration probe.'); 176 177 set_error_handler(static fn() => true); 178 try { 179 return (bool)$mailer->send(); 180 } finally { 181 restore_error_handler(); 182 } 183 } 184 185 /** 186 * Reduce a Mailpit address list to a plain list of email addresses 187 * 188 * @param array $list list of {Name, Address} entries as returned by Mailpit 189 * @return string[] 190 */ 191 protected function addresses(array $list): array 192 { 193 return array_map(static fn($addr) => $addr['Address'], $list); 194 } 195 196 /** 197 * Find a message in Mailpit whose subject contains the given needle 198 * 199 * DokuWiki prefixes the subject with the wiki title (eg. "[My Wiki] ..."), so we 200 * match on a unique substring rather than the full subject. Mailpit stores the 201 * message before acknowledging the SMTP DATA command, so it is available as soon 202 * as Mailer::send() returns - no polling needed. 203 * 204 * @param string $needle a unique substring of the subject 205 * @return array|null the message stub from the listing or null if not found 206 */ 207 protected function findMessageBySubject($needle) 208 { 209 $list = $this->apiGet('/api/v1/messages'); 210 foreach ($list['messages'] as $msg) { 211 if (strpos($msg['Subject'], $needle) !== false) return $msg; 212 } 213 return null; 214 } 215 216 /** 217 * Perform a GET request against the Mailpit API and return the decoded JSON 218 * 219 * @param string $path API path including the leading slash 220 * @return array 221 */ 222 protected function apiGet($path) 223 { 224 $client = new DokuHTTPClient(); 225 $body = $client->get($this->apiBase . $path); 226 $this->assertNotFalse($body, 'Mailpit API request to ' . $path . ' failed: ' . $client->error); 227 return json_decode($body, true); 228 } 229} 230