Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.50% covered (warning)
84.50%
349 / 413
58.33% covered (warning)
58.33%
14 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProcessEmailCommand
84.50% covered (warning)
84.50%
349 / 413
58.33% covered (warning)
58.33%
14 / 24
169.81
0.00% covered (danger)
0.00%
0 / 1
 getAllowedAppEnvs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configure
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 handle
91.11% covered (success)
91.11%
41 / 45
0.00% covered (danger)
0.00%
0 / 1
13.12
 shouldDoCleanup
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 getProcessedMails
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getInboxMails
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMails
43.75% covered (danger)
43.75%
7 / 16
0.00% covered (danger)
0.00%
0 / 1
12.41
 getMailsQuery
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 archiveOldProcessedMails
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 deleteOldArchivedMails
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 deleteOldSpamMails
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 deleteMailsOlderThan
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getIsMessageIdProcessed
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 processMail
77.42% covered (warning)
77.42%
24 / 31
0.00% covered (danger)
0.00%
0 / 1
5.29
 processMailToAddress
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
11
 processMailToBot
69.23% covered (warning)
69.23%
27 / 39
0.00% covered (danger)
0.00%
0 / 1
17.92
 getSpamNoticeScore
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 forwardEmailToUser
84.27% covered (warning)
84.27%
75 / 89
0.00% covered (danger)
0.00%
0 / 1
22.72
 recordForwardedEmail
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getAddresses
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getAddress
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 sendRedirectEmail
70.37% covered (warning)
70.37%
19 / 27
0.00% covered (danger)
0.00%
0 / 1
8.27
 sendReportEmail
71.43% covered (warning)
71.43%
15 / 21
0.00% covered (danger)
0.00%
0 / 1
5.58
 getReportMessage
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
5.01
1<?php
2
3namespace Olz\Command;
4
5use Olz\Command\Common\OlzCommand;
6use Olz\Entity\ForwardedEmail;
7use Olz\Entity\Roles\Role;
8use Olz\Entity\Throttling;
9use Olz\Entity\Users\User;
10use Symfony\Component\Console\Attribute\AsCommand;
11use Symfony\Component\Console\Command\Command;
12use Symfony\Component\Console\Input\InputInterface;
13use Symfony\Component\Console\Output\OutputInterface;
14use Symfony\Component\Mailer\Envelope;
15use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
16use Symfony\Component\Mime\Address;
17use Symfony\Component\Mime\Email;
18use Symfony\Component\Mime\Exception\RfcComplianceException;
19use Symfony\Component\Mime\Part\DataPart;
20use Symfony\Component\Mime\Part\File;
21use Webklex\PHPIMAP\Attribute;
22use Webklex\PHPIMAP\Client;
23use Webklex\PHPIMAP\Exceptions\ConnectionFailedException;
24use Webklex\PHPIMAP\Exceptions\ImapServerErrorException;
25use Webklex\PHPIMAP\Exceptions\ResponseException;
26use Webklex\PHPIMAP\Message;
27use Webklex\PHPIMAP\Query\WhereQuery;
28use Webklex\PHPIMAP\Support\MessageCollection;
29
30// 2 = processed
31// 1 = spam
32// 0 = failed
33
34#[AsCommand(name: 'olz:process-email')]
35class ProcessEmailCommand extends OlzCommand {
36    /** @return array<string> */
37    protected function getAllowedAppEnvs(): array {
38        return ['dev', 'test', 'staging', 'prod'];
39    }
40
41    public const MAX_LOOP = 100;
42    public int $archiveAfterSeconds = 8 * 60 * 60;
43    public int $deleteArchivedAfterSeconds = 30 * 24 * 60 * 60;
44    public int $deleteSpamAfterSeconds = 365 * 24 * 60 * 60;
45    public string $host = '';
46    public string $processed_mailbox = 'INBOX.Processed';
47    public string $archive_mailbox = 'INBOX.Archive';
48    public string $failed_mailbox = 'INBOX.Failed';
49    public string $spam_mailbox = 'INBOX.Spam';
50    /** @var array<string> */
51    public array $spam_report_froms = ['MAILER-DAEMON@219.hosttech.eu'];
52    /** @var array<string> */
53    public array $spam_report_subjects = ['Undelivered Mail Returned to Sender'];
54    /** @var array<string, int> */
55    public array $spam_report_body_patterns = [
56        '/[^0-9]550[^0-9]/' => 1,
57        '/[^0-9]554[^0-9]/' => 1,
58        '/[^0-9]5\.2\.0[^0-9]/' => 1,
59        '/[^0-9]5\.7\.509[^0-9]/' => 1,
60        '/URL\s+in\s+this\s+email/i' => 2,
61        '/\Wlisted\W/i' => 1,
62        '/\Wreputation\W/i' => 1,
63        '/\Wreject(ed)?\W/i' => 1,
64        '/\Waddress\s+rejected\W/i' => -1,
65        '/\Wpolicy\W/i' => 1,
66        '/\WDMARC\W/i' => 2,
67        '/\Wspam\W/i' => 3,
68        '/Message\s+rejected\s+due\s+to\s+local\s+policy/i' => 2,
69        '/spamrl\.com/i' => 3,
70        '/No\s+Such\s+User\s+Here/im' => -1,
71    ];
72    public int $min_spam_notice_score = 3;
73
74    protected Client $client;
75
76    protected function configure(): void {
77        parent::configure();
78        $this->host = $this->envUtils()->getEmailForwardingHost();
79    }
80
81    protected function handle(InputInterface $input, OutputInterface $output): int {
82        ini_set('memory_limit', '500M');
83
84        try {
85            $this->client = $this->emailUtils()->getImapClient();
86            $this->client->connect();
87        } catch (ConnectionFailedException $exc) {
88            $this->log()->error("Failed to connect to IMAP: {$exc->getMessage()}", [$exc]);
89            return Command::SUCCESS;
90        } catch (\Throwable $th) {
91            $this->log()->error("Error connecting to IMAP: {$th->getMessage()}", [$th]);
92            return Command::FAILURE;
93        }
94
95        $mailboxes = [
96            $this->processed_mailbox,
97            $this->archive_mailbox,
98            $this->failed_mailbox,
99            $this->spam_mailbox,
100        ];
101        foreach ($mailboxes as $mailbox) {
102            try {
103                $this->client->createFolder($mailbox);
104            } catch (ImapServerErrorException $exc) {
105                // ignore when folder already exists
106            }
107        }
108
109        if ($this->shouldDoCleanup()) {
110            $this->log()->notice("Doing E-Mail cleanup now...");
111            $this->deleteOldArchivedMails();
112            $this->deleteOldSpamMails();
113            $throttling_repo = $this->entityManager()->getRepository(Throttling::class);
114            $throttling_repo->recordOccurrenceOf('email_cleanup', $this->dateUtils()->getIsoNow());
115        }
116
117        $processed_mails = $this->getProcessedMails();
118        $this->archiveOldProcessedMails($processed_mails);
119        $is_message_id_processed = $this->getIsMessageIdProcessed($processed_mails);
120        $inbox_mails = $this->getInboxMails();
121        $newly_processed_mails = [];
122        $newly_spam_mails = [];
123        foreach ($inbox_mails as $mail) {
124            $message_id = $mail->message_id ? $mail->message_id->first() : null;
125            $is_processed = ($is_message_id_processed[$message_id] ?? false);
126            $result = $this->processMail($mail, $is_processed);
127            if ($result === 2) {
128                $newly_processed_mails[] = $mail;
129            } elseif ($result === 1) {
130                $newly_spam_mails[] = $mail;
131            }
132            if ($message_id !== null) {
133                $is_message_id_processed[$message_id] = true;
134            }
135        }
136
137        foreach ($newly_processed_mails as $mail) {
138            $mail->move($folder_path = $this->processed_mailbox);
139        }
140        foreach ($newly_spam_mails as $mail) {
141            $mail->move($folder_path = $this->spam_mailbox);
142        }
143
144        return Command::SUCCESS;
145    }
146
147    public function shouldDoCleanup(): bool {
148        $throttling_repo = $this->entityManager()->getRepository(Throttling::class);
149        $last_cleanup = $throttling_repo->getLastOccurrenceOf('email_cleanup');
150        if (!$last_cleanup) {
151            return true;
152        }
153        $now = new \DateTime($this->dateUtils()->getIsoNow());
154        $min_interval = \DateInterval::createFromDateString('+1 week');
155        $min_now = $last_cleanup->add($min_interval);
156        return $now > $min_now;
157    }
158
159    protected function getProcessedMails(): MessageCollection {
160        return $this->getMails($this->processed_mailbox);
161    }
162
163    protected function getInboxMails(): MessageCollection {
164        return $this->getMails('INBOX');
165    }
166
167    protected function getMails(string $folder_path, mixed $where = null): MessageCollection {
168        try {
169            $query = $this->getMailsQuery($folder_path);
170            if ($where !== null) {
171                $query->where($where);
172            } else {
173                $query->all();
174            }
175            $messages = $query->get();
176            foreach ($query->errors() as $error) {
177                $this->log()->warning("getMails soft error:", [$error]);
178            }
179            return $messages;
180        } catch (ResponseException $exc) {
181            if (!preg_match('/Empty response/i', $exc->getMessage())) {
182                $this->log()->critical("ResponseException in getMails: {$exc->getMessage()}", [$exc]);
183                throw $exc;
184            }
185            return new MessageCollection([]);
186        } catch (\Exception $exc) {
187            $this->log()->critical("Exception in getMails: {$exc->getMessage()}", [$exc]);
188            throw $exc;
189        }
190    }
191
192    protected function getMailsQuery(string $folder_path): WhereQuery {
193        $folder = $this->client->getFolderByPath($folder_path);
194        $query = $folder?->messages();
195        $this->generalUtils()->checkNotNull($query, "Error listing messages in {$folder_path}");
196        $query->softFail();
197        $query->leaveUnread();
198        $query->setFetchBody(false);
199        return $query;
200    }
201
202    protected function archiveOldProcessedMails(MessageCollection $processed_mails): void {
203        $now_timestamp = strtotime($this->dateUtils()->getIsoNow()) ?: 0;
204        foreach ($processed_mails as $mail) {
205            $message_timestamp = $mail->date->first()->timestamp;
206            $should_archive = $message_timestamp < $now_timestamp - $this->archiveAfterSeconds;
207            if ($should_archive) {
208                $mail->move($folder_path = $this->archive_mailbox);
209            }
210        }
211    }
212
213    protected function deleteOldArchivedMails(): void {
214        $this->log()->info("Removing old archived E-Mails...");
215        $this->getMailsQuery($this->archive_mailbox)->all()->chunked(
216            function (MessageCollection $archived_mails, int $chunk) {
217                $this->deleteMailsOlderThan($archived_mails, $this->deleteArchivedAfterSeconds);
218            },
219            $chunk_size = 100
220        );
221    }
222
223    protected function deleteOldSpamMails(): void {
224        $this->log()->info("Removing old spam E-Mails...");
225        $this->getMailsQuery($this->spam_mailbox)->all()->chunked(
226            function (MessageCollection $spam_mails, int $chunk) {
227                $this->deleteMailsOlderThan($spam_mails, $this->deleteSpamAfterSeconds);
228            },
229            $chunk_size = 100,
230        );
231    }
232
233    protected function deleteMailsOlderThan(MessageCollection $mails, int $seconds): void {
234        $now_timestamp = strtotime($this->dateUtils()->getIsoNow()) ?: 0;
235        foreach ($mails as $mail) {
236            $message_timestamp = $mail->date->first()->timestamp;
237            $should_delete = $message_timestamp < $now_timestamp - $seconds;
238            if ($should_delete) {
239                $mail->delete($expunge = true);
240            }
241        }
242    }
243
244    /** @return array<int|string, bool> */
245    protected function getIsMessageIdProcessed(MessageCollection $processed_mails): array {
246        $is_message_id_processed = [];
247        foreach ($processed_mails as $mail) {
248            $message_id = $mail->message_id ? $mail->message_id->first() : null;
249            if ($message_id !== null) {
250                $is_message_id_processed[$message_id] = true;
251            }
252        }
253        return $is_message_id_processed;
254    }
255
256    protected function processMail(Message $mail, bool $is_processed): int {
257        $mail_uid = $mail->getUid();
258
259        if ($mail->getFlags()->has('flagged')) {
260            $this->log()->warning("E-Mail {$mail_uid} has failed processing.");
261            $mail->move($folder_path = $this->failed_mailbox);
262            $mail->unsetFlag('seen');
263            $this->sendReportEmail($mail, null, 431);
264            return 2;
265        }
266
267        $original_to = $mail->get('x_original_to');
268        if ($original_to) {
269            return $this->processMailToAddress($mail, $original_to);
270        }
271        if ($is_processed) {
272            $this->log()->info("E-Mail {$mail_uid} already processed.");
273            return 2;
274        }
275        $to_addresses = array_map(function ($address) {
276            return $address->mail;
277        }, $mail->getTo()->toArray());
278        $cc_addresses = array_map(function ($address) {
279            return $address->mail;
280        }, $mail->getCc()->toArray());
281        $bcc_addresses = array_map(function ($address) {
282            return $address->mail;
283        }, $mail->getBcc()->toArray());
284        $all_addresses = [
285            ...$to_addresses,
286            ...$cc_addresses,
287            ...$bcc_addresses,
288        ];
289        $all_successful = 2;
290        foreach ($all_addresses as $address) {
291            $all_successful = min($all_successful, $this->processMailToAddress($mail, $address));
292        }
293        return $all_successful;
294    }
295
296    protected function processMailToAddress(Message $mail, string $address): int {
297        $mail_uid = $mail->getUid();
298
299        $esc_host = preg_quote($this->host);
300        $is_match = preg_match("/^([\\S]+)@(staging\\.)?{$esc_host}$/", $address, $matches);
301        if (!$is_match) {
302            $this->log()->info("E-Mail {$mail_uid} to non-{$this->host} address: {$address}");
303            return 2;
304        }
305        $username = $matches[1];
306        if ($this->emailUtils()->isSpamEmailAddress($username)) {
307            $this->log()->info("Received honeypot spam E-Mail to: {$username}");
308            return 1;
309        }
310
311        $role_repo = $this->entityManager()->getRepository(Role::class);
312        $role = $role_repo->findRoleFuzzilyByUsername($username);
313        if (!$role) {
314            $role = $role_repo->findRoleFuzzilyByOldUsername($username);
315            if ($role) {
316                $this->sendRedirectEmail($mail, $address, "{$role->getUsername()}@{$this->host}");
317            }
318        }
319        if ($role != null) {
320            $role_users = $role->getUsers();
321            $all_successful = 2;
322            foreach ($role_users as $role_user) {
323                $all_successful = min($all_successful, $this->forwardEmailToUser($mail, $role_user, $address));
324            }
325            return $all_successful;
326        }
327
328        $user_repo = $this->entityManager()->getRepository(User::class);
329        $user = $user_repo->findUserFuzzilyByUsername($username);
330        if (!$user) {
331            $user = $user_repo->findUserFuzzilyByOldUsername($username);
332            if ($user) {
333                $this->sendRedirectEmail($mail, $address, "{$user->getUsername()}@{$this->host}");
334            }
335        }
336        if ($user != null) {
337            return $this->forwardEmailToUser($mail, $user, $address);
338        }
339
340        $smtp_from = $this->envUtils()->getSmtpFrom();
341        if ($address === $smtp_from) {
342            $this->log()->info("E-Mail {$mail_uid} to bot...");
343            return $this->processMailToBot($mail);
344        }
345
346        $this->log()->info("E-Mail {$mail_uid} to inexistent user/role username: {$username}");
347        $this->sendReportEmail($mail, $address, 550);
348        return 2;
349    }
350
351    protected function processMailToBot(Message $mail): int {
352        $from = $mail->getFrom()->first()->mail;
353        $subject = $mail->getSubject()->first();
354        if (
355            array_search($from, $this->spam_report_froms) === false
356            || array_search($subject, $this->spam_report_subjects) === false
357        ) {
358            $this->log()->info("E-Mail \"{$subject}\" from {$from} to bot. Nothing to do.");
359            return 2;
360        }
361        $mail->parseBody();
362        $html = $mail->hasHTMLBody() ? $mail->getHTMLBody() : '';
363        $text = $mail->hasTextBody() ? $mail->getTextBody() : '';
364        $body = "{$html}\n\n{$text}";
365        $score = $this->getSpamNoticeScore($body);
366        $min_score = $this->min_spam_notice_score;
367        $this->log()->info("Spam notice score {$score} of {$min_score}", [$body]);
368        if ($score < $min_score) {
369            $this->log()->info("Delivery notice E-Mail from {$from} to bot", []);
370            return 2;
371        }
372        $spam_message_id = null;
373        $attachments = $mail->hasAttachments() ? $mail->getAttachments() : [];
374        foreach ($attachments as $attachment_id => $attachment) {
375            $content = $attachment->getContent();
376            $has_reference = preg_match('/(\s|^)References:\s*<([^>]+)>\s*\n/', $content, $matches);
377            if ($has_reference) {
378                $spam_message_id = $matches[2];
379            }
380        }
381        if (!$spam_message_id) {
382            $this->log()->notice("Spam notice E-Mail from {$from} to bot has no References header", []);
383            return 2;
384        }
385        $this->log()->info("Spam notice E-Mail from {$from} to bot: Message-ID \"{$spam_message_id}\" is spam", []);
386        $processed_mails = $this->getMails($this->processed_mailbox);
387        $message_found = false;
388        foreach ($processed_mails as $processed_mail) {
389            $message_id = $processed_mail->getMessageId()->first();
390            if ($message_id !== $spam_message_id) {
391                continue;
392            }
393            $processed_mail->move($folder_path = $this->spam_mailbox);
394            $this->log()->info("Spam E-Mail with Message-ID \"{$spam_message_id}\" moved", []);
395            $message_found = true;
396        }
397        if (!$message_found) {
398            $this->log()->notice("Spam E-Mail with Message-ID \"{$spam_message_id}\" not found!", []);
399        }
400        return 2;
401    }
402
403    protected function getSpamNoticeScore(string $body): int {
404        $num_spam_matches = 0;
405        foreach ($this->spam_report_body_patterns as $pattern => $increment) {
406            if (preg_match($pattern, $body)) {
407                $num_spam_matches += $increment;
408            }
409        }
410        return $num_spam_matches;
411    }
412
413    protected function forwardEmailToUser(Message $mail, User $user, string $address): int {
414        $mail->setFlag('flagged');
415
416        $forward_address = $user->getEmail();
417        $from_address = '?';
418        $subject = '?';
419        $html = '?';
420        $text = '?';
421        try {
422            $from = $mail->getFrom()->first();
423            $from_name = $from->personal;
424            $from_address = $from->mail;
425            $to = $this->getAddresses($mail->getTo());
426            $cc = $this->getAddresses($mail->getCc());
427            $bcc = $this->getAddresses($mail->getBcc());
428            if (count($to) + count($cc) + count($bcc) === 0) { // E-Mail to undisclosed recipients
429                $to = [new Address($this->envUtils()->getSmtpFrom(), 'Undisclosed Recipients')];
430            }
431            $message_id = $mail->getMessageId()->first();
432            $subject = $mail->getSubject()->first();
433            $mail->parseBody();
434            $html = $mail->hasHTMLBody() ? $mail->getHTMLBody() : null;
435            $text = $mail->hasTextBody() ? $mail->getTextBody() : null;
436            if (!$html) {
437                $html = nl2br($text ?? '');
438            }
439            if (!$forward_address) {
440                $error_message = "User {$user->getUsername()} does not have an email address";
441                $this->log()->notice($error_message);
442                $this->recordForwardedEmail($user, $from_address, $subject, $html."\n".$text, $error_message);
443                return 2;
444            }
445            $this->emailUtils()->setLogger($this->log());
446
447            $email = (new Email())
448                ->from(new Address($from_address, $from_name))
449                ->replyTo(new Address($from_address, $from_name))
450                ->to(...$to)
451                ->cc(...$cc)
452                ->bcc(...$bcc)
453                ->subject($subject)
454                ->text($text ? $text : '(leer)')
455                ->html($html ? $html : '(leer)')
456            ;
457            if ($message_id) {
458                $email->getHeaders()->addIdHeader("References", [$message_id]);
459            }
460
461            if ($mail->hasAttachments()) {
462                $attachments = $mail->getAttachments();
463                $data_path = $this->envUtils()->getDataPath();
464                $temp_path = "{$data_path}temp/";
465                if (!is_dir($temp_path)) {
466                    mkdir($temp_path, 0o777, true);
467                }
468                foreach ($attachments as $attachment_id => $attachment) {
469                    gc_collect_cycles();
470                    $upload_id = '';
471                    $upload_path = '';
472                    $continue = true;
473                    for ($i = 0; $i < self::MAX_LOOP && $continue; $i++) {
474                        try {
475                            $ext = strrchr($attachment->name, '.') ?: '.data';
476                            $upload_id = $this->uploadUtils()->getRandomUploadId($ext);
477                        } catch (\Throwable $th) {
478                            $upload_id = $this->uploadUtils()->getRandomUploadId('.data');
479                        }
480
481                        $upload_path = "{$temp_path}{$upload_id}";
482                        if (!is_file($upload_path)) {
483                            $continue = false;
484                        }
485                    }
486                    $this->log()->info("Saving attachment {$attachment->name} to {$upload_id}...");
487                    if ($attachment->save($temp_path, $upload_id)) {
488                        $email = $email->addPart(new DataPart(new File($upload_path), $attachment->name));
489                    } else {
490                        throw new \Exception("Could not save attachment {$attachment->name} to {$upload_id}.");
491                    }
492                    gc_collect_cycles();
493                }
494            }
495
496            $default_envelope = Envelope::create($email);
497            $sender = $default_envelope->getSender();
498            $envelope = new Envelope($sender, [$this->emailUtils()->getUserAddress($user)]);
499
500            $this->emailUtils()->send($email, $envelope);
501
502            $this->log()->info("Email forwarded from {$address} to {$forward_address}");
503            $this->recordForwardedEmail($user, $from_address, $subject, $html."\n".$text, null);
504
505            $mail->unsetFlag('flagged');
506            return 2;
507        } catch (RfcComplianceException $exc) {
508            $message = $exc->getMessage();
509            $error_message = "Email from {$address} to {$forward_address} is not RFC-compliant: {$message}";
510            $this->log()->notice($error_message, [$exc]);
511            $this->recordForwardedEmail($user, $from_address, $subject, $html."\n".$text, $error_message);
512            return 2;
513        } catch (TransportExceptionInterface $exc) {
514            $message = $exc->getMessage();
515            $error_message = "Error sending email (from {$address}) to {$forward_address}{$message}";
516            $this->log()->error($error_message, [$exc]);
517            $this->recordForwardedEmail($user, $from_address, $subject, $html."\n".$text, $error_message);
518            return 0;
519        } catch (\Exception $exc) {
520            $message = $exc->getMessage();
521            $error_message = "Error forwarding email from {$address} to {$forward_address}{$message}";
522            $this->log()->critical($error_message, [$exc]);
523            $this->recordForwardedEmail($user, $from_address, $subject, $html."\n".$text, $error_message);
524            return 0;
525        }
526    }
527
528    protected function recordForwardedEmail(
529        User $recipient_user,
530        string $sender_address,
531        string $subject,
532        string $body,
533        ?string $error_message,
534    ): void {
535        $now = new \DateTime($this->dateUtils()->getIsoNow());
536        $entity = new ForwardedEmail();
537        $entity->setRecipientUser($recipient_user);
538        $entity->setSenderAddress($sender_address);
539        $entity->setSubject($subject);
540        $entity->setBody($body);
541        $entity->setForwardedAt($now);
542        $entity->setErrorMessage($error_message);
543        $this->entityManager()->persist($entity);
544        $this->entityManager()->flush();
545    }
546
547    /** @return array<Address> */
548    protected function getAddresses(Attribute $field): array {
549        $addresses = [];
550        foreach ($field->toArray() as $item) {
551            if (empty($item->mail)) {
552                continue;
553            }
554            // @phpstan-ignore-next-line
555            $address = $this->getAddress($item);
556            if ($address === null) {
557                continue;
558            }
559            $addresses[] = $address;
560        }
561        return $addresses;
562    }
563
564    protected function getAddress(\Webklex\PHPIMAP\Address $item): ?Address {
565        try {
566            if ($item->personal) {
567                return new Address($item->mail, $item->personal);
568            }
569            return new Address($item->mail);
570        } catch (RfcComplianceException $exc) {
571            $this->log()->notice("Skipping non-RFC-compliant email address {$item->personal}<{$item->mail}>: {$exc->getMessage()}", [$exc]);
572            return null;
573        }
574    }
575
576    protected function sendRedirectEmail(Message $mail, string $old_address, string $new_address): void {
577        $smtp_from = $this->envUtils()->getSmtpFrom();
578        $from = $mail->getFrom()->first();
579        $from_name = $from->personal;
580        $from_address = $from->mail;
581        if (
582            "{$old_address}" === "{$smtp_from}"
583            || "{$old_address}" === "{$from_address}"
584            || "{$new_address}" === "{$smtp_from}"
585            || "{$new_address}" === "{$from_address}"
586        ) {
587            $this->log()->notice("sendRedirectEmail: Avoiding email loop for redirect {$old_address} => {$new_address}");
588            return;
589        }
590
591        try {
592            $email = (new Email())
593                ->from(new Address($smtp_from, 'OLZ Bot'))
594                ->to(new Address($from_address, $from_name))
595                ->subject("Empfänger hat eine neue E-Mail-Adresse")
596                ->text(<<<ZZZZZZZZZZ
597                    Hallo {$from_name} ({$from_address}),
598
599                    Dies ist eine Mitteilung der E-Mail-Weiterleitung:
600                    Die E-Mail-Adresse "{$old_address}" ist neu unter "{$new_address}" erreichbar.
601
602                    Dies nur zur Information. Ihre E-Mail wurde automatisch weitergeleitet!
603                    ZZZZZZZZZZ)
604            ;
605            $this->emailUtils()->send($email);
606
607            $this->log()->info("Redirect E-Mail sent to {$from_address}{$old_address} -> {$new_address}", []);
608        } catch (RfcComplianceException $exc) {
609            $message = $exc->getMessage();
610            $this->log()->notice("sendRedirectEmail: Email to {$from_address} is not RFC-compliant: {$message}", [$exc]);
611            return;
612        } catch (\Throwable $th) {
613            $this->log()->error("Failed to send redirect email to {$from_address}{$th->getMessage()}", [$th]);
614        }
615    }
616
617    protected function sendReportEmail(Message $mail, ?string $address, int $smtp_code): void {
618        $smtp_from = $this->envUtils()->getSmtpFrom();
619        $from = $mail->getFrom()->first();
620        $from_name = $from->personal;
621        $from_address = $from->mail;
622        if ("{$address}" === "{$smtp_from}" || "{$address}" === "{$from_address}") {
623            $this->log()->notice("sendReportEmail: Avoiding email loop for {$address}");
624            return;
625        }
626
627        try {
628            $email = (new Email())
629                ->from(new Address($smtp_from, 'OLZ Bot'))
630                ->to(new Address($from_address, $from_name))
631                ->subject("Undelivered Mail Returned to Sender")
632                ->text($this->getReportMessage($smtp_code, $mail, $address))
633            ;
634            $this->emailUtils()->send($email);
635            $this->log()->info("Report E-Mail sent to {$from_address}", []);
636        } catch (RfcComplianceException $exc) {
637            $message = $exc->getMessage();
638            $this->log()->notice("sendReportEmail: Email to {$from_address} is not RFC-compliant: {$message}", [$exc]);
639            return;
640        } catch (\Throwable $th) {
641            $this->log()->error("Failed to send report email to {$from_address}{$th->getMessage()}", [$th]);
642        }
643    }
644
645    public function getReportMessage(int $smtp_code, Message $mail, ?string $address): string {
646        $message_by_code = [
647            431 => "{$smtp_code} Not enough storage or out of memory",
648            550 => "<{$address}>: {$smtp_code} sorry, no mailbox here by that name",
649        ];
650
651        $error_message = $message_by_code[$smtp_code] ?? "{$smtp_code} Unknown error";
652
653        $report_message = <<<ZZZZZZZZZZ
654            This is the mail system at host {$this->host}.
655
656            I'm sorry to have to inform you that your message could not
657            be delivered to one or more recipients.
658
659                            The mail system
660
661            {$error_message}
662            ZZZZZZZZZZ;
663
664        if ($smtp_code === 550) {
665            $all_attributes = '';
666            foreach ($mail->getAttributes() as $key => $value) {
667                $padded_name = str_pad($key, 18, ' ', STR_PAD_LEFT);
668                $all_attributes .= "{$padded_name}{$value}\n";
669            }
670
671            $mail->parseBody();
672            $body = '(no body)';
673            if ($mail->hasHTMLBody()) {
674                $body = $mail->getHTMLBody();
675            } elseif ($mail->hasTextBody()) {
676                $body = $mail->getTextBody();
677            }
678
679            return <<<ZZZZZZZZZZ
680                {$report_message}
681
682                ------ This is a copy of the message, including all the headers. ------
683
684                {$all_attributes}
685
686                {$body}
687                ZZZZZZZZZZ;
688        }
689
690        return $report_message;
691    }
692}