1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2021 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 Exception; 23use Fisharebest\Webtrees\Contracts\UserInterface; 24use Fisharebest\Webtrees\I18N; 25use Fisharebest\Webtrees\Log; 26use Fisharebest\Webtrees\Site; 27use Psr\Http\Message\ServerRequestInterface; 28use Swift_Mailer; 29use Swift_Message; 30use Swift_NullTransport; 31use Swift_SendmailTransport; 32use Swift_Signers_DKIMSigner; 33use Swift_SmtpTransport; 34use Swift_Transport; 35use Throwable; 36 37use function assert; 38use function checkdnsrr; 39use function filter_var; 40use function function_exists; 41use function gethostbyaddr; 42use function gethostbyname; 43use function gethostname; 44use function str_replace; 45use function strrchr; 46use function substr; 47 48use const FILTER_VALIDATE_DOMAIN; 49use const FILTER_VALIDATE_EMAIL; 50 51/** 52 * Send emails. 53 */ 54class EmailService 55{ 56 /** 57 * Send an external email message 58 * Caution! gmail may rewrite the "From" header unless you have added the address to your account. 59 * 60 * @param UserInterface $from 61 * @param UserInterface $to 62 * @param UserInterface $reply_to 63 * @param string $subject 64 * @param string $message_text 65 * @param string $message_html 66 * 67 * @return bool 68 */ 69 public function send(UserInterface $from, UserInterface $to, UserInterface $reply_to, string $subject, string $message_text, string $message_html): bool 70 { 71 // Mail needs MSDOS line endings 72 $message_text = str_replace("\n", "\r\n", $message_text); 73 $message_html = str_replace("\n", "\r\n", $message_html); 74 75 // Special accounts do not have an email address. Use the system one. 76 $from_email = $from->email() ?: $this->senderEmail(); 77 $reply_to_email = $reply_to->email() ?: $this->senderEmail(); 78 79 try { 80 $message = (new Swift_Message()) 81 ->setSubject($subject) 82 ->setFrom($from_email, $from->realName()) 83 ->setTo($to->email(), $to->realName()) 84 ->setBody($message_html, 'text/html'); 85 86 if ($from_email !== $reply_to_email) { 87 $message->setReplyTo($reply_to_email, $reply_to->realName()); 88 } 89 90 $dkim_domain = Site::getPreference('DKIM_DOMAIN'); 91 $dkim_selector = Site::getPreference('DKIM_SELECTOR'); 92 $dkim_key = Site::getPreference('DKIM_KEY'); 93 94 if ($dkim_domain !== '' && $dkim_selector !== '' && $dkim_key !== '') { 95 $signer = new Swift_Signers_DKIMSigner($dkim_key, $dkim_domain, $dkim_selector); 96 $signer 97 ->setHeaderCanon('relaxed') 98 ->setBodyCanon('relaxed'); 99 100 $message->attachSigner($signer); 101 } else { 102 // DKIM body hashes don't work with multipart/alternative content. 103 $message->addPart($message_text, 'text/plain'); 104 } 105 106 $mailer = new Swift_Mailer($this->transport()); 107 108 $mailer->send($message); 109 } catch (Exception $ex) { 110 Log::addErrorLog('MailService: ' . $ex->getMessage()); 111 112 return false; 113 } 114 115 return true; 116 } 117 118 /** 119 * Create a transport mechanism for sending mail 120 * 121 * @return Swift_Transport 122 */ 123 private function transport(): Swift_Transport 124 { 125 switch (Site::getPreference('SMTP_ACTIVE')) { 126 case 'sendmail': 127 // Local sendmail (requires PHP proc_* functions) 128 $request = app(ServerRequestInterface::class); 129 assert($request instanceof ServerRequestInterface); 130 131 $sendmail_command = $request->getAttribute('sendmail_command', '/usr/sbin/sendmail -bs'); 132 133 return new Swift_SendmailTransport($sendmail_command); 134 135 case 'external': 136 // SMTP 137 $smtp_host = Site::getPreference('SMTP_HOST'); 138 $smtp_port = (int) Site::getPreference('SMTP_PORT', '25'); 139 $smtp_auth = (bool) Site::getPreference('SMTP_AUTH'); 140 $smtp_user = Site::getPreference('SMTP_AUTH_USER'); 141 $smtp_pass = Site::getPreference('SMTP_AUTH_PASS'); 142 $smtp_encr = Site::getPreference('SMTP_SSL'); 143 144 if ($smtp_encr === 'none') { 145 $smtp_encr = null; 146 } 147 148 $transport = new Swift_SmtpTransport($smtp_host, $smtp_port, $smtp_encr); 149 150 $transport->setLocalDomain($this->localDomain()); 151 152 if ($smtp_auth) { 153 $transport 154 ->setUsername($smtp_user) 155 ->setPassword($smtp_pass); 156 } 157 158 return $transport; 159 160 default: 161 // For testing 162 return new Swift_NullTransport(); 163 } 164 } 165 166 /** 167 * Where are we sending mail from? 168 * 169 * @return string 170 */ 171 public function localDomain(): string 172 { 173 $local_domain = Site::getPreference('SMTP_HELO'); 174 175 try { 176 // Look ourself up using DNS. 177 $default = gethostbyaddr(gethostbyname(gethostname())); 178 } catch (Throwable $ex) { 179 $default = 'localhost'; 180 } 181 182 return $local_domain ?: $default; 183 } 184 185 /** 186 * Who are we sending mail from? 187 * 188 * @return string 189 */ 190 public function senderEmail(): string 191 { 192 $sender = Site::getPreference('SMTP_FROM_NAME'); 193 $default = 'no-reply@' . $this->localDomain(); 194 195 return $sender ?: $default; 196 } 197 198 /** 199 * Many mail relays require a valid sender email. 200 * 201 * @param string $email 202 * 203 * @return bool 204 */ 205 public function isValidEmail(string $email): bool 206 { 207 $at_domain = strrchr($email, '@'); 208 209 if ($at_domain === false) { 210 return false; 211 } 212 213 $domain = substr($at_domain, 1); 214 215 $email_valid = filter_var($email, FILTER_VALIDATE_EMAIL) !== false; 216 $domain_valid = filter_var($domain, FILTER_VALIDATE_DOMAIN) !== false; 217 218 // Some web hosts disable checkdnsrr. 219 if ($domain_valid && function_exists('checkdnsrr')) { 220 $domain_valid = checkdnsrr($domain); 221 } 222 223 return $email_valid && $domain_valid; 224 } 225 226 /** 227 * A list SSL modes (e.g. for an edit control). 228 * 229 * @return array<string> 230 */ 231 public function mailSslOptions(): array 232 { 233 return [ 234 'none' => I18N::translate('none'), 235 /* I18N: Secure Sockets Layer - a secure communications protocol*/ 236 'ssl' => I18N::translate('ssl'), 237 /* I18N: Transport Layer Security - a secure communications protocol */ 238 'tls' => I18N::translate('tls'), 239 ]; 240 } 241 242 /** 243 * A list SSL modes (e.g. for an edit control). 244 * 245 * @return array<string> 246 */ 247 public function mailTransportOptions(): array 248 { 249 $options = [ 250 /* I18N: "sendmail" is the name of some mail software */ 251 'sendmail' => I18N::translate('Use sendmail to send messages'), 252 'external' => I18N::translate('Use SMTP to send messages'), 253 ]; 254 255 if (!function_exists('proc_open')) { 256 unset($options['sendmail']); 257 } 258 259 return $options; 260 } 261} 262