Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
93.87% |
199 / 212 |
|
75.00% |
12 / 16 |
CRAP | |
0.00% |
0 / 1 |
| EmailUtils | |
93.87% |
199 / 212 |
|
75.00% |
12 / 16 |
32.24 | |
0.00% |
0 / 1 |
| sendEmailVerificationEmail | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
2 | |||
| getRandomEmailVerificationToken | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getImapClient | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
1 | |||
| buildOlzEmail | |
97.73% |
43 / 44 |
|
0.00% |
0 / 1 |
4 | |||
| getUserAddress | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
| getComparableEmail | |
95.83% |
23 / 24 |
|
0.00% |
0 / 1 |
2 | |||
| getComparableEnvelope | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| arr2str | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| send | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
| encryptEmailReactionToken | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| decryptEmailReactionToken | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| renderMarkdown | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
| generateSpamEmailAddress | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
2 | |||
| isSpamEmailAddress | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
3 | |||
| obfuscateEmail | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
| getPageAndTimeBasedRandomInt | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace Olz\Utils; |
| 4 | |
| 5 | use League\CommonMark\Environment\Environment; |
| 6 | use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; |
| 7 | use League\CommonMark\Extension\GithubFlavoredMarkdownExtension; |
| 8 | use League\CommonMark\MarkdownConverter; |
| 9 | use Olz\Entity\Users\User; |
| 10 | use Symfony\Component\Mailer\Envelope; |
| 11 | use Symfony\Component\Mime\Address; |
| 12 | use Symfony\Component\Mime\Email; |
| 13 | use Symfony\Component\Mime\Part\DataPart; |
| 14 | use Symfony\Component\Mime\Part\File; |
| 15 | use Webklex\PHPIMAP\Client; |
| 16 | use Webklex\PHPIMAP\ClientManager; |
| 17 | |
| 18 | class 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 | } |