Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
93.43% |
199 / 213 |
|
70.59% |
12 / 17 |
CRAP | |
0.00% |
0 / 1 |
EmailUtils | |
93.43% |
199 / 213 |
|
70.59% |
12 / 17 |
33.31 | |
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 | |||
fromEnv | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
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 | |
339 | public static function fromEnv(): self { |
340 | return new self(); |
341 | } |
342 | } |