Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.43% covered (success)
93.43%
199 / 213
70.59% covered (warning)
70.59%
12 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
EmailUtils
93.43% covered (success)
93.43%
199 / 213
70.59% covered (warning)
70.59%
12 / 17
33.31
0.00% covered (danger)
0.00%
0 / 1
 sendEmailVerificationEmail
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
2
 getRandomEmailVerificationToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getImapClient
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 buildOlzEmail
97.73% covered (success)
97.73%
43 / 44
0.00% covered (danger)
0.00%
0 / 1
4
 getUserAddress
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 getComparableEmail
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
2
 getComparableEnvelope
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 arr2str
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 send
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 encryptEmailReactionToken
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 decryptEmailReactionToken
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 renderMarkdown
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 generateSpamEmailAddress
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 isSpamEmailAddress
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 obfuscateEmail
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getPageAndTimeBasedRandomInt
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 fromEnv
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace Olz\Utils;
4
5use League\CommonMark\Environment\Environment;
6use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
7use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
8use League\CommonMark\MarkdownConverter;
9use Olz\Entity\Users\User;
10use Symfony\Component\Mailer\Envelope;
11use Symfony\Component\Mime\Address;
12use Symfony\Component\Mime\Email;
13use Symfony\Component\Mime\Part\DataPart;
14use Symfony\Component\Mime\Part\File;
15use Webklex\PHPIMAP\Client;
16use Webklex\PHPIMAP\ClientManager;
17
18class EmailUtils {
19    use WithUtilsTrait;
20
21    public function sendEmailVerificationEmail(User $user): void {
22        $user_id = $user->getId();
23        $email_verification_token = $this->getRandomEmailVerificationToken();
24        $user->setEmailVerificationToken($email_verification_token);
25        $verify_email_token = urlencode($this->encryptEmailReactionToken([
26            'action' => 'verify_email',
27            'user' => $user_id,
28            'email' => $user->getEmail(),
29            'token' => $email_verification_token,
30        ]));
31        $base_url = $this->envUtils()->getBaseHref();
32        $code_href = $this->envUtils()->getCodeHref();
33        $verify_email_url = "{$base_url}{$code_href}email_reaktion?token={$verify_email_token}";
34        $text = <<<ZZZZZZZZZZ
35            **!!! Falls du nicht soeben auf olzimmerberg.ch deine E-Mail-Adresse bestätigen wolltest, lösche diese E-Mail !!!**
36
37            Hallo {$user->getFirstName()},
38
39            *Um deine E-Mail-Adresse zu bestätigen*, klicke [hier]({$verify_email_url}) oder auf folgenden Link:
40
41            {$verify_email_url}
42
43            ZZZZZZZZZZ;
44        $config = [
45            'no_unsubscribe' => true,
46        ];
47
48        try {
49            $email = (new Email())->subject("[OLZ] E-Mail bestätigen");
50            $email = $this->buildOlzEmail($email, $user, $text, $config);
51            $this->send($email);
52            $this->log()->info("Email verification email sent to user ({$user_id}).");
53        } catch (\Exception $exc) {
54            $message = $exc->getMessage();
55            $full_message = "Error sending email verification email to user ({$user_id}): {$message}";
56            $this->log()->critical($full_message);
57            throw new \Exception($full_message);
58        }
59    }
60
61    protected function getRandomEmailVerificationToken(): string {
62        return $this->generalUtils()->base64EncodeUrl(openssl_random_pseudo_bytes(6));
63    }
64
65    public function getImapClient(): Client {
66        $env_utils = $this->envUtils();
67        $imap_host = $env_utils->getImapHost();
68        $imap_port = $env_utils->getImapPort();
69        $imap_flags = $env_utils->getImapFlags();
70        $imap_username = $env_utils->getImapUsername();
71        $imap_password = $env_utils->getImapPassword();
72
73        $cm = new ClientManager([
74            'options' => [
75                'fallback_date' => '1970-01-01 00:00:00',
76            ],
77        ]);
78        return $cm->make([
79            'host' => $imap_host,
80            'port' => $imap_port,
81            // TODO: Load encryption, validate_cert and protocol from config.
82            'encryption' => 'ssl',
83            'validate_cert' => false,
84            'username' => $imap_username,
85            'password' => $imap_password,
86            'protocol' => 'imap',
87        ]);
88
89        // Documentation at:
90        //    https://www.php-imap.com/api/client
91        //    https://github.com/Webklex/php-imap
92    }
93
94    /** @param array{no_header?: bool, no_unsubscribe?: bool, notification_type?: string} $config */
95    public function buildOlzEmail(Email $email, User $user, string $text, array $config): Email {
96        // TODO: Check if verified?
97        $user_id = $user->getId();
98        $email = $email->to($this->getUserAddress($user));
99        $html_text = $this->renderMarkdown($text);
100        $html_header = "";
101        if (!($config['no_header'] ?? false)) {
102            $email = $email->addPart((new DataPart(new File(__DIR__.'/../../assets/icns/olz_logo_schwarzweiss_300.png'), 'olz_logo', 'image/png'))->asInline());
103            $html_header = <<<'ZZZZZZZZZZ'
104                <div style="text-align: right; float: right;">
105                    <img src="cid:olz_logo" alt="" style="width:150px;" />
106                </div>
107                <br /><br /><br />
108                ZZZZZZZZZZ;
109        }
110        $html_unsubscribe = "";
111        $text_unsubscribe = "";
112        if (!($config['no_unsubscribe'] ?? false)) {
113            if (!isset($config['notification_type'])) {
114                $this->log()->warning("E-Mail has no notification_type (to user: {$user_id}): {$html_text}");
115            }
116            $unsubscribe_this_token = urlencode($this->encryptEmailReactionToken([
117                'action' => 'unsubscribe',
118                'user' => $user_id,
119                'notification_type' => $config['notification_type'] ?? null,
120            ]));
121            $unsubscribe_all_token = urlencode($this->encryptEmailReactionToken([
122                'action' => 'unsubscribe',
123                'user' => $user_id,
124                'notification_type_all' => true,
125            ]));
126            $base_url = $this->envUtils()->getBaseHref();
127            $code_href = $this->envUtils()->getCodeHref();
128            $unsubscribe_this_url = "{$base_url}{$code_href}email_reaktion?token={$unsubscribe_this_token}";
129            $unsubscribe_all_url = "{$base_url}{$code_href}email_reaktion?token={$unsubscribe_all_token}";
130            $html_unsubscribe = <<<ZZZZZZZZZZ
131                <br /><br />
132                <hr style="border: 0; border-top: 1px solid black;">
133                Abmelden? <a href="{$unsubscribe_this_url}">Keine solchen E-Mails mehr</a> oder <a href="{$unsubscribe_all_url}">Keine E-Mails von OL Zimmerberg mehr</a>
134                ZZZZZZZZZZ;
135            $text_unsubscribe = <<<ZZZZZZZZZZ
136
137                ---
138                Abmelden?
139                Keine solchen E-Mails mehr: {$unsubscribe_this_url}
140                Keine E-Mails von OL Zimmerberg mehr: {$unsubscribe_all_url}
141                ZZZZZZZZZZ;
142        }
143        $email = $email->text(<<<ZZZZZZZZZZ
144            {$text}
145            {$text_unsubscribe}
146            ZZZZZZZZZZ);
147        return $email->html(<<<ZZZZZZZZZZ
148            {$html_header}
149            {$html_text}
150            {$html_unsubscribe}
151            ZZZZZZZZZZ);
152    }
153
154    public function getUserAddress(User $user): Address {
155        $user_email = $user->getEmail();
156        if (empty($user_email)) {
157            throw new \Exception("getUserAddress: {$user} has no email.");
158        }
159        $user_full_name = $user->getFullName();
160        return new Address($user_email, $user_full_name);
161    }
162
163    public function getComparableEmail(?Email $email): ?string {
164        if ($email === null) {
165            return null;
166        }
167        $from = $this->arr2str($email->getFrom());
168        $reply_to = $this->arr2str($email->getReplyTo());
169        $to = $this->arr2str($email->getTo());
170        $cc = $this->arr2str($email->getCc());
171        $bcc = $this->arr2str($email->getBcc());
172        $subject = $email->getSubject();
173        $text_body = $email->getTextBody() ?? '(no text body)';
174        $html_body = $email->getHtmlBody() ?? '(no html body)';
175        $attachments = implode('', array_map(function (DataPart $data_part) {
176            return "\n".$data_part->getFilename();
177        }, $email->getAttachments()));
178
179        return <<<ZZZZZZZZZZ
180            From: {$from}
181            Reply-To: {$reply_to}
182            To: {$to}
183            Cc: {$cc}
184            Bcc: {$bcc}
185            Subject: {$subject}
186
187            {$text_body}
188
189            {$html_body}
190            {$attachments}
191            ZZZZZZZZZZ;
192    }
193
194    public function getComparableEnvelope(?Envelope $envelope): ?string {
195        if ($envelope === null) {
196            return null;
197        }
198        $sender = $envelope->getSender()->toString();
199        $recipients = $this->arr2str($envelope->getRecipients());
200        return <<<ZZZZZZZZZZ
201            Sender: {$sender}
202            Recipients: {$recipients}
203            ZZZZZZZZZZ;
204    }
205
206    /** @param array<mixed> $arr */
207    protected function arr2str(array $arr): string {
208        return implode(', ', array_map(function ($item) {
209            return $item->toString();
210        }, $arr));
211    }
212
213    public function send(Email $email, ?Envelope $envelope = null): void {
214        $app_env = $this->envUtils()->getAppEnv();
215        if ($app_env === 'dev' || $app_env === 'test') {
216            $data_path = $this->envUtils()->getDataPath();
217            file_put_contents(
218                "{$data_path}last_email.txt",
219                "{$this->getComparableEnvelope($envelope)}\n\n{$this->getComparableEmail($email)}"
220            );
221        }
222        $to = $this->arr2str($email->getTo());
223        $recipients = $this->arr2str($envelope?->getRecipients() ?? []);
224        $this->log()->debug("Sending email to {$to} ({$recipients})");
225        $this->mailer->send($email, $envelope);
226    }
227
228    // ---
229
230    public function encryptEmailReactionToken(mixed $data): string {
231        $key = $this->envUtils()->getEmailReactionKey();
232        return $this->generalUtils()->encrypt($key, $data);
233    }
234
235    public function decryptEmailReactionToken(string $token): mixed {
236        $key = $this->envUtils()->getEmailReactionKey();
237        try {
238            return $this->generalUtils()->decrypt($key, $token);
239        } catch (\Throwable $th) {
240            return null;
241        }
242    }
243
244    public function renderMarkdown(string $markdown): string {
245        $environment = new Environment([
246            'html_input' => 'strip',
247            'allow_unsafe_links' => false,
248            'max_nesting_level' => 100,
249        ]);
250        $environment->addExtension(new CommonMarkCoreExtension());
251        $environment->addExtension(new GithubFlavoredMarkdownExtension());
252        $converter = new MarkdownConverter($environment);
253        $rendered = $converter->convert($markdown);
254        return strval($rendered);
255    }
256
257    public function generateSpamEmailAddress(): string {
258        $part_3_options = ['mann', 'matheisen', 'meissen', 'melzer', 'mettler', 'moll', 'munz'];
259
260        $part3 = $part_3_options[$this->getPageAndTimeBasedRandomInt(0, count($part_3_options) - 1)];
261
262        if ($this->getPageAndTimeBasedRandomInt(0, 1) === 1) {
263            $part_0_options = ['severin', 'simon', 'sebastian', 'samuel', 'sascha', 'sven', 'stefan'];
264            $part_1_options = ['pascal', 'patrick', 'paul', 'peter', 'philipp'];
265            $part_2_options = ['adam', 'alex', 'andreas', 'albert', 'anton'];
266
267            $part0 = $part_0_options[$this->getPageAndTimeBasedRandomInt(0, count($part_0_options) - 1)];
268            $part1 = $part_1_options[$this->getPageAndTimeBasedRandomInt(0, count($part_1_options) - 1)];
269            $part2 = $part_2_options[$this->getPageAndTimeBasedRandomInt(0, count($part_2_options) - 1)];
270
271            return "{$part0}.{$part1}.{$part2}.{$part3}";
272        }
273
274        $part_0_options = ['sabine', 'salome', 'samira', 'sara', 'silvia', 'sophie', 'svenja'];
275        $part_1_options = ['pamela', 'patricia', 'paula', 'petra', 'philippa', 'pia'];
276        $part_2_options = ['alena', 'alice', 'alva', 'amelie', 'amy', 'anja', 'anne', 'andrea'];
277
278        $part0 = $part_0_options[$this->getPageAndTimeBasedRandomInt(0, count($part_0_options) - 1)];
279        $part1 = $part_1_options[$this->getPageAndTimeBasedRandomInt(0, count($part_1_options) - 1)];
280        $part2 = $part_2_options[$this->getPageAndTimeBasedRandomInt(0, count($part_2_options) - 1)];
281
282        return "{$part0}.{$part1}.{$part2}.{$part3}";
283    }
284
285    public function isSpamEmailAddress(string $username): bool {
286        // Denylist
287        $denylist = [
288            // Old Addresses
289            'jeweils' => true,
290            'fotoposten' => true,
291            // Appear in code
292            'fake-user' => true,
293            'beispiel' => true,
294            'admin' => true,
295            'admin-old' => true,
296            'admin_role' => true,
297            'vorstand' => true,
298            'vorstand_role' => true,
299            'inexistent' => true,
300            'test.adress' => true, // sic!
301            'test.address' => true,
302        ];
303        if (($denylist[$username] ?? false) === true) {
304            return true;
305        }
306
307        // Non-E-Mail Identifiers
308        if (preg_match('/^olz_termin_[0-9]+(|_end|_start)$/i', $username)) {
309            return true;
310        }
311
312        // Honeypot
313        return (bool) preg_match('/^s[a-z]*\.p[a-z]*\.a[a-z]*\.m[a-z]*$/i', $username);
314    }
315
316    /** @return ?array<non-empty-string> */
317    public function obfuscateEmail(?string $email): ?array {
318        if (!$email) {
319            return null;
320        }
321        $chunks = [];
322        while (strlen($email) > 0) {
323            $chunks[] = substr($email, 0, 4);
324            $email = substr($email, 4);
325        }
326        return array_map(
327            fn ($chunk) => $this->generalUtils()->base64EncodeUrl($chunk) ?: '=',
328            $chunks,
329        );
330    }
331
332    protected function getPageAndTimeBasedRandomInt(int $min, int $max): int {
333        $page_int = crc32($this->server()['REQUEST_URI'] ?? '');
334        $time_int = intval($this->dateUtils()->getCurrentDateInFormat('Ym'));
335        mt_srand($page_int ^ $time_int);
336        return mt_rand($min, $max);
337    }
338
339    public static function fromEnv(): self {
340        return new self();
341    }
342}