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');
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