1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2023 webtrees development team 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License as published by 8 * the Free Software Foundation, either version 3 of the License, or 9 * (at your option) any later version. 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * You should have received a copy of the GNU General Public License 15 * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 */ 17 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees\Services; 21 22use Fisharebest\Webtrees\Contracts\UserInterface; 23use Fisharebest\Webtrees\I18N; 24use Fisharebest\Webtrees\Log; 25use Fisharebest\Webtrees\Site; 26use Fisharebest\Webtrees\Validator; 27use Psr\Http\Message\ServerRequestInterface; 28use Symfony\Component\Mailer\Exception\TransportExceptionInterface; 29use Symfony\Component\Mailer\Mailer; 30use Symfony\Component\Mailer\Transport\NullTransport; 31use Symfony\Component\Mailer\Transport\SendmailTransport; 32use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; 33use Symfony\Component\Mailer\Transport\TransportInterface; 34use Symfony\Component\Mime\Address; 35use Symfony\Component\Mime\Crypto\DkimOptions; 36use Symfony\Component\Mime\Crypto\DkimSigner; 37use Symfony\Component\Mime\Email; 38use Symfony\Component\Mime\Exception\RfcComplianceException; 39use Symfony\Component\Mime\Message; 40 41use function assert; 42use function checkdnsrr; 43use function function_exists; 44use function str_replace; 45use function strrchr; 46use function substr; 47 48/** 49 * Send emails. 50 */ 51class EmailService 52{ 53 /** 54 * Send an external email message 55 * Caution! gmail may rewrite the "From" header unless you have added the address to your account. 56 * 57 * @param UserInterface $from 58 * @param UserInterface $to 59 * @param UserInterface $reply_to 60 * @param string $subject 61 * @param string $message_text 62 * @param string $message_html 63 * 64 * @return bool 65 */ 66 public function send(UserInterface $from, UserInterface $to, UserInterface $reply_to, string $subject, string $message_text, string $message_html): bool 67 { 68 try { 69 $message = $this->message($from, $to, $reply_to, $subject, $message_text, $message_html); 70 $transport = $this->transport(); 71 $mailer = new Mailer($transport); 72 $mailer->send($message); 73 } catch (RfcComplianceException $ex) { 74 Log::addErrorLog('Cannot create email ' . $ex->getMessage()); 75 76 return false; 77 } catch (TransportExceptionInterface $ex) { 78 Log::addErrorLog('Cannot send email: ' . $ex->getMessage()); 79 80 return false; 81 } 82 83 return true; 84 } 85 86 /** 87 * Create a message 88 * 89 * @param UserInterface $from 90 * @param UserInterface $to 91 * @param UserInterface $reply_to 92 * @param string $subject 93 * @param string $message_text 94 * @param string $message_html 95 * 96 * @return Message 97 */ 98 protected function message(UserInterface $from, UserInterface $to, UserInterface $reply_to, string $subject, string $message_text, string $message_html): Message 99 { 100 // Mail needs MS-DOS line endings 101 $message_text = str_replace("\n", "\r\n", $message_text); 102 $message_html = str_replace("\n", "\r\n", $message_html); 103 104 $message = (new Email()) 105 ->subject($subject) 106 ->from(new Address($from->email(), $from->realName())) 107 ->to(new Address($to->email(), $to->realName())) 108 ->replyTo(new Address($reply_to->email(), $reply_to->realName())) 109 ->html($message_html); 110 111 $dkim_domain = Site::getPreference('DKIM_DOMAIN'); 112 $dkim_selector = Site::getPreference('DKIM_SELECTOR'); 113 $dkim_key = Site::getPreference('DKIM_KEY'); 114 115 if ($dkim_domain !== '' && $dkim_selector !== '' && $dkim_key !== '') { 116 $signer = new DkimSigner($dkim_key, $dkim_domain, $dkim_selector); 117 $options = (new DkimOptions()) 118 ->headerCanon('relaxed') 119 ->bodyCanon('relaxed'); 120 121 return $signer->sign($message, $options->toArray()); 122 } 123 124 // DKIM body hashes don't work with multipart/alternative content. 125 $message->text($message_text); 126 127 return $message; 128 } 129 130 /** 131 * Create a transport mechanism for sending mail 132 * 133 * @return TransportInterface 134 */ 135 protected function transport(): TransportInterface 136 { 137 switch (Site::getPreference('SMTP_ACTIVE')) { 138 case 'sendmail': 139 // Local sendmail (requires PHP proc_* functions) 140 $request = app(ServerRequestInterface::class); 141 assert($request instanceof ServerRequestInterface); 142 143 $sendmail_command = Validator::attributes($request)->string('sendmail_command', '/usr/sbin/sendmail -bs'); 144 145 return new SendmailTransport($sendmail_command); 146 147 case 'external': 148 // SMTP 149 $smtp_helo = Site::getPreference('SMTP_HELO'); 150 $smtp_host = Site::getPreference('SMTP_HOST'); 151 $smtp_port = (int) Site::getPreference('SMTP_PORT'); 152 $smtp_auth = (bool) Site::getPreference('SMTP_AUTH'); 153 $smtp_user = Site::getPreference('SMTP_AUTH_USER'); 154 $smtp_pass = Site::getPreference('SMTP_AUTH_PASS'); 155 $smtp_encr = Site::getPreference('SMTP_SSL') === 'ssl'; 156 157 $transport = new EsmtpTransport($smtp_host, $smtp_port, $smtp_encr); 158 159 $transport->setLocalDomain($smtp_helo); 160 161 if ($smtp_auth) { 162 $transport 163 ->setUsername($smtp_user) 164 ->setPassword($smtp_pass); 165 } 166 167 return $transport; 168 169 default: 170 // For testing 171 return new NullTransport(); 172 } 173 } 174 175 /** 176 * Many mail relays require a valid sender email. 177 * 178 * @param string $email 179 * 180 * @return bool 181 */ 182 public function isValidEmail(string $email): bool 183 { 184 try { 185 $address = new Address($email); 186 } catch (RfcComplianceException) { 187 return false; 188 } 189 190 // Some web hosts disable checkdnsrr. 191 if (function_exists('checkdnsrr')) { 192 $domain = substr(strrchr($address->getAddress(), '@') ?: '@', 1); 193 return checkdnsrr($domain); 194 } 195 196 return true; 197 } 198 199 /** 200 * A list SSL modes (e.g. for an edit control). 201 * 202 * @return array<string> 203 */ 204 public function mailSslOptions(): array 205 { 206 return [ 207 'none' => I18N::translate('none'), 208 /* I18N: Use SMTP over SSL/TLS, or Implicit TLS - a secure communications protocol */ 209 'ssl' => I18N::translate('SSL/TLS'), 210 /* I18N: Use SMTP with STARTTLS, or Explicit TLS - a secure communications protocol */ 211 'tls' => I18N::translate('STARTTLS'), 212 ]; 213 } 214 215 /** 216 * A list SSL modes (e.g. for an edit control). 217 * 218 * @return array<string> 219 */ 220 public function mailTransportOptions(): array 221 { 222 $options = [ 223 /* I18N: "sendmail" is the name of some mail software */ 224 'sendmail' => I18N::translate('Use sendmail to send messages'), 225 'external' => I18N::translate('Use SMTP to send messages'), 226 ]; 227 228 if (!function_exists('proc_open')) { 229 unset($options['sendmail']); 230 } 231 232 return $options; 233 } 234} 235