Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
85.19% |
328 / 385 |
|
56.52% |
13 / 23 |
CRAP | |
0.00% |
0 / 1 |
ProcessEmailCommand | |
85.19% |
328 / 385 |
|
56.52% |
13 / 23 |
157.92 | |
0.00% |
0 / 1 |
getAllowedAppEnvs | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
configure | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
handle | |
91.11% |
41 / 45 |
|
0.00% |
0 / 1 |
13.12 | |||
shouldDoCleanup | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
2.01 | |||
getProcessedMails | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getInboxMails | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getMails | |
43.75% |
7 / 16 |
|
0.00% |
0 / 1 |
12.41 | |||
getMailsQuery | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
archiveOldProcessedMails | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
deleteOldArchivedMails | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
deleteOldSpamMails | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
deleteMailsOlderThan | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
getIsMessageIdProcessed | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
4.07 | |||
processMail | |
77.42% |
24 / 31 |
|
0.00% |
0 / 1 |
5.29 | |||
processMailToAddress | |
100.00% |
47 / 47 |
|
100.00% |
1 / 1 |
13 | |||
processMailToBot | |
69.23% |
27 / 39 |
|
0.00% |
0 / 1 |
17.92 | |||
getSpamNoticeScore | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
forwardEmailToUser | |
90.14% |
64 / 71 |
|
0.00% |
0 / 1 |
19.35 | |||
getAddresses | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
getAddress | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
sendRedirectEmail | |
66.67% |
16 / 24 |
|
0.00% |
0 / 1 |
8.81 | |||
sendReportEmail | |
71.43% |
15 / 21 |
|
0.00% |
0 / 1 |
5.58 | |||
getReportMessage | |
92.31% |
24 / 26 |
|
0.00% |
0 / 1 |
5.01 |
1 | <?php |
2 | |
3 | namespace Olz\Command; |
4 | |
5 | use Olz\Command\Common\OlzCommand; |
6 | use Olz\Entity\Roles\Role; |
7 | use Olz\Entity\Throttling; |
8 | use Olz\Entity\Users\User; |
9 | use Symfony\Component\Console\Attribute\AsCommand; |
10 | use Symfony\Component\Console\Command\Command; |
11 | use Symfony\Component\Console\Input\InputInterface; |
12 | use Symfony\Component\Console\Output\OutputInterface; |
13 | use Symfony\Component\Mailer\Envelope; |
14 | use Symfony\Component\Mailer\Exception\TransportExceptionInterface; |
15 | use Symfony\Component\Mime\Address; |
16 | use Symfony\Component\Mime\Email; |
17 | use Symfony\Component\Mime\Exception\RfcComplianceException; |
18 | use Symfony\Component\Mime\Part\DataPart; |
19 | use Symfony\Component\Mime\Part\File; |
20 | use Webklex\PHPIMAP\Attribute; |
21 | use Webklex\PHPIMAP\Client; |
22 | use Webklex\PHPIMAP\Exceptions\ConnectionFailedException; |
23 | use Webklex\PHPIMAP\Exceptions\ImapServerErrorException; |
24 | use Webklex\PHPIMAP\Exceptions\ResponseException; |
25 | use Webklex\PHPIMAP\Message; |
26 | use Webklex\PHPIMAP\Query\WhereQuery; |
27 | use Webklex\PHPIMAP\Support\MessageCollection; |
28 | |
29 | // 2 = processed |
30 | // 1 = spam |
31 | // 0 = failed |
32 | |
33 | #[AsCommand(name: 'olz:process-email')] |
34 | class 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 | } |