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