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