Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
58.65% covered (warning)
58.65%
122 / 208
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Mail
58.65% covered (warning)
58.65%
122 / 208
28.57% covered (danger)
28.57%
2 / 7
234.84
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 initQueueTransmission
40.00% covered (danger)
40.00%
20 / 50
0.00% covered (danger)
0.00%
0 / 1
79.42
 sendQueueItems
73.08% covered (warning)
73.08%
57 / 78
0.00% covered (danger)
0.00%
0 / 1
14.81
 getValidMailer
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
6
 readMailer
70.45% covered (warning)
70.45%
31 / 44
0.00% covered (danger)
0.00%
0 / 1
12.58
 startProcess
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 deleteEntitiesFromQueue
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 *
5* @package Zmsmessaging
6*
7*/
8
9namespace BO\Zmsmessaging;
10
11use BO\Zmsentities\Mimepart;
12use BO\Zmsentities\Mail as MailEntity;
13use PHPMailer\PHPMailer\PHPMailer;
14use PHPMailer\PHPMailer\Exception as PHPMailerException;
15
16class Mail extends BaseController
17{
18    protected $messagesQueue = null;
19    protected $startTime;
20
21    public function __construct($verbose = false, $maxRunTime = 50)
22    {
23        parent::__construct($verbose, $maxRunTime);
24        $this->log("Read Mail QueueList start with limit " . \App::$mails_per_minute . " - " . \App::$now->format('c'));
25        $queueList = \App::$http->readGetResult('/mails/', [
26            'resolveReferences' => 0,
27            'limit' => \App::$mails_per_minute,
28            'onlyIds' => true
29        ])->getCollection();
30        if (null !== $queueList) {
31            $this->messagesQueue = $queueList->sortByCustomKey('createTimestamp');
32        } else {
33            $this->log("QueueList is null - " . \App::$now->format('c'));
34        }
35    }
36
37    /**
38     * @return (mixed|string[])[]
39     *
40     */
41    public function initQueueTransmission($action = false): array
42    {
43        $resultList = [];
44        if ($this->messagesQueue && count($this->messagesQueue)) {
45            if ($this->maxRunTime < $this->getSpendTime()) {
46                $this->log("Max Runtime exceeded before processing started - " . \App::$now->format('c'));
47                return $resultList;
48            }
49            $this->log("Messages queue count - " . count($this->messagesQueue));
50            if (count($this->messagesQueue) <= 50) {
51                $this->log("Less than or equal to 50 items, sending immediately.");
52
53                $itemIds = [];
54                foreach ($this->messagesQueue as $item) {
55                    if ($this->maxRunTime < $this->getSpendTime()) {
56                        $this->log("Max Runtime exceeded during message loop - " . \App::$now->format('c'));
57                        break;
58                    }
59                    $itemIds[] = $item['id'];
60                }
61
62                if (!empty($itemIds)) {
63                    try {
64                        $results = $this->sendQueueItems($action, $itemIds);
65                        foreach ($results as $result) {
66                            $resultList[] = $result;
67                            if (isset($result['errorInfo'])) {
68                                $this->log("Error processing mail item: " . $result['errorInfo']);
69                            }
70                        }
71                    } catch (\Exception $exception) {
72                        $this->log("Error processing mail items: " . $exception->getMessage());
73                        $resultList[] = [
74                            'errorInfo' => $exception->getMessage()
75                        ];
76                    }
77                }
78            } else {
79                $batchSize = min(count($this->messagesQueue), max(1, ceil(count($this->messagesQueue) / 12)));
80                $this->log("More than 50 items, processing in batches of $batchSize.");
81                $batches = array_chunk(iterator_to_array($this->messagesQueue), $batchSize);
82                $this->log("Messages divided into " . count($batches) . " batches.");
83
84                $processHandles = [];
85                foreach ($batches as $batch) {
86                    if ($this->maxRunTime < $this->getSpendTime()) {
87                        $this->log("Max Runtime exceeded during batch processing - " . \App::$now->format('c'));
88                        break;
89                    }
90
91                    $ids = array_map(function ($message) {
92                        return $message['id'];
93                    }, $batch);
94                    $encodedIds = base64_encode(json_encode($ids));
95                    $actionStr = is_array($action) ? json_encode($action) : ($action === false ? 'false' : ($action === true ? 'true' : (string)$action));
96
97                    $idsStr = implode(', ', $ids);
98                    $command = "php " . escapeshellarg(__DIR__ . '/MailProcessor.php') . " " . escapeshellarg($encodedIds) . " " . escapeshellarg($actionStr);
99                    $processHandles[] = $this->startProcess($command, $idsStr);
100                }
101
102                if ($this->maxRunTime >= $this->getSpendTime()) {
103                    $this->monitorProcesses($processHandles);
104                } else {
105                    $this->log("Max Runtime exceeded before process monitoring started - " . \App::$now->format('c'));
106                }
107            }
108        } else {
109            $resultList[] = [
110                'errorInfo' => 'No mail entry found in Database...',
111            ];
112            $this->log('No mail entry found in Database');
113        }
114
115        return $resultList;
116    }
117
118    /**
119     * @return ((array|mixed|null|string)[]|string)[]
120     *
121     */
122    public function sendQueueItems($action, array $itemIds): array
123    {
124        $endpoint = '/mails/';
125        $params = [
126            'resolveReferences' => 2,
127            'ids' => implode(',', $itemIds)
128        ];
129
130        try {
131            $response = \App::$http->readGetResult($endpoint, $params);
132            $mailItems = $response->getCollection();
133        } catch (\Exception $e) {
134            $this->log("Error fetching mail data: " . $e->getMessage() . "\n\n");
135            return ['errorInfo' => 'Failed to fetch mail data'];
136        }
137
138        if (empty($mailItems)) {
139            $this->log("No mail items found for the provided IDs.");
140            return ['errorInfo' => 'No mail items found'];
141        }
142
143        $results = [];
144        $processedMails = [];
145        $successfullySentIds = [];
146
147        foreach ($mailItems as $item) {
148            $entity = new MailEntity($item);
149            $processId = $entity['process']['id'] ?? null;
150            $mailer = $this->getValidMailer($entity);
151            if (!$mailer) {
152                $this->log("No valid mailer for mail ID: " . $entity->id);
153                continue;
154            }
155
156            try {
157                $result = $this->sendMailer($entity, $mailer, $action);
158                if ($result instanceof PHPMailer) {
159                    $mailResult = [
160                        'id' => ($result->getLastMessageID()) ? $result->getLastMessageID() : $entity->id,
161                        'mailId' => $entity->id,
162                        'processId' => $processId,
163                        'createTimestamp' => $entity->createTimestamp,
164                        'recipients' => $result->getAllRecipientAddresses(),
165                        'mime' => $result->getMailMIME(),
166                        'attachments' => $result->getAttachments(),
167                        'customHeaders' => $result->getCustomHeaders(),
168                    ];
169                    $results[] = $mailResult;
170                    $processedMails[] = [
171                        'mailId' => $entity->id,
172                        'processId' => $processId,
173                        'createTimestamp' => $entity->createTimestamp,
174                    ];
175                    \App::$log->info('Mail processed from queue', [
176                        'mailId' => $entity->id,
177                        'processId' => $processId,
178                        'createTimestamp' => $entity->createTimestamp,
179                    ]);
180                    $successfullySentIds[] = $entity->id;
181                } else {
182                    $errorInfo = $result->ErrorInfo ?? 'Unknown mailer error';
183                    $results[] = [
184                        'errorInfo' => $errorInfo,
185                        'mailId' => $entity->id,
186                        'processId' => $processId,
187                    ];
188                    $this->log('Mail send failed with error: ' . $errorInfo);
189                }
190            } catch (\Exception $e) {
191                $this->log("Exception while sending mail ID " . $entity->id . ": " . $e->getMessage());
192                $results[] = [
193                    'errorInfo' => $e->getMessage(),
194                    'mailId' => $entity->id,
195                    'processId' => $processId,
196                ];
197            }
198        }
199
200        if ($action && !empty($successfullySentIds)) {
201            try {
202                $this->deleteEntitiesFromQueue($successfullySentIds);
203            } catch (\Exception $e) {
204                $this->log("Error deleting processed mails: " . $e->getMessage());
205            }
206        }
207
208        if (!empty($processedMails)) {
209            \App::$log->info('Mail queue batch finished', [
210                'count' => count($processedMails),
211                'mails' => $processedMails,
212            ]);
213            $this->log(
214                'Processing finished for IDs [emailId, processId, createdTimestamp)]: '
215                . implode(', ', array_map(
216                    static fn (array $mail) => '[' . $mail['mailId'] . ', ' . $mail['processId'] . ', ' . $mail['createTimestamp'] . ']',
217                    $processedMails
218                ))
219            );
220        }
221
222        return $results;
223    }
224
225    protected function getValidMailer(MailEntity $entity)
226    {
227        $message = '';
228        $messageId = $entity['id'];
229        try {
230            $mailer = $this->readMailer($entity);
231        // @codeCoverageIgnoreStart
232        } catch (PHPMailerException $exception) {
233            $message = "Message #$messageId PHPMailer Failure: " . $exception->getMessage();
234            $code = $exception->getCode();
235            \App::$log->warning($message, []);
236        } catch (\Exception $exception) {
237            $message = "Message #$messageId Exception Failure: " . $exception->getMessage();
238            $code = $exception->getCode();
239            \App::$log->warning($message, []);
240        }
241        if ($message) {
242            if (428 == $code || 422 == $code) {
243                $this->log("Build Mailer Failure " . $code . ": deleteEntityFromQueue() - " . \App::$now->format('c'));
244                $this->deleteEntityFromQueue($entity);
245            } else {
246                $this->log(
247                    "Build Mailer Failure " . $code . ": removeEntityOlderThanOneHour() - " . \App::$now->format('c')
248                );
249                $this->removeEntityOlderThanOneHour($entity);
250            }
251
252            $log = new Mimepart(['mime' => 'text/plain']);
253            $log->content = $message;
254            $this->log("Build Mailer Exception log message: " . $message);
255            \App::$http->readPostResult('/log/process/' . $entity->process['id'] . '/', $log, ['error' => 1]);
256            return false;
257        }
258
259        // @codeCoverageIgnoreEnd
260        return $mailer;
261    }
262
263    /**
264     * @SuppressWarnings("CyclomaticComplexity")
265     * @SuppressWarnings("NPathComplexity")
266     */
267    protected function readMailer(MailEntity $entity): PHPMailer
268    {
269        $this->testEntity($entity);
270        $encoding = 'base64';
271        foreach ($entity->multipart as $part) {
272            $mimepart = new Mimepart($part);
273            if ($mimepart->isText()) {
274                $textPart = $mimepart->getContent();
275            }
276            if ($mimepart->isHtml()) {
277                $htmlPart = $mimepart->getContent();
278            }
279            if ($mimepart->isIcs()) {
280                $icsPart = $mimepart->getContent();
281            }
282        }
283        $mailer = new PHPMailer(true);
284        $mailer->CharSet = 'UTF-8';
285        $mailer->SMTPDebug = \App::$smtp_debug;
286        $mailer->SetLanguage("de");
287        $mailer->Encoding = $encoding;
288        $mailer->IsHTML(true);
289        $mailer->XMailer = \App::IDENTIFIER;
290        $mailer->Subject = $entity['subject'];
291        $mailer->AltBody = (isset($textPart)) ? $textPart : '';
292        $mailer->Body = (isset($htmlPart)) ? $htmlPart : '';
293        $mailer->SetFrom($entity['department']['email'], $entity['department']['name']);
294        $mailer->AddAddress($entity->getRecipient(), $entity->client['familyName']);
295
296        if (null !== $entity->getIcsPart()) {
297            $mailer->AddStringAttachment(
298                $icsPart,
299                "Termin.ics",
300                $encoding,
301                "text/calendar; charset=utf-8; method=REQUEST"
302            );
303        }
304
305        if (\App::$smtp_enabled) {
306            $mailer->IsSMTP();
307            $mailer->SMTPAuth = \App::$smtp_auth_enabled;
308            $mailer->SMTPSecure = \App::$smtp_auth_method;
309            $mailer->Port = \App::$smtp_port;
310            $mailer->Host = \App::$smtp_host;
311            $mailer->Username = \App::$smtp_username;
312            $mailer->Password = \App::$smtp_password;
313            if (\App::$smtp_skip_tls_verify) {
314                $mailer->SMTPOptions['ssl'] = [
315                    'verify_peer' => false,
316                    'verify_peer_name' => false,
317                    'allow_self_signed' => true,
318                ];
319            }
320        }
321
322        return $mailer;
323    }
324
325    /**
326     * @return (mixed|resource|resource[])[]|null
327     *
328     */
329    private function startProcess(string $command, string $ids): array|null
330    {
331        $descriptorSpec = [
332            0 => ["pipe", "r"], // stdin
333            1 => ["pipe", "w"], // stdout
334            2 => ["pipe", "w"]  // stderr
335        ];
336
337        $process = proc_open($command . ' 2>&1', $descriptorSpec, $pipes); // Redirect stderr to stdout
338        if (is_resource($process)) {
339            return [
340                'process' => $process,
341                'pipes' => $pipes,
342                'ids' => $ids
343            ];
344        } else {
345            return null;
346        }
347    }
348
349    private function deleteEntitiesFromQueue(array $itemIds)
350    {
351        $endpoint = '/mails/';
352        $params = [
353            'ids' => implode(',', $itemIds)
354        ];
355
356        try {
357            $response = \App::$http->readDeleteResult($endpoint, $params);
358            return $response;
359        } catch (\Exception $e) {
360            $this->log("Error deleting mail data: " . $e->getMessage() . "\n\n");
361            throw new \Exception("Failed to delete mail data");
362        }
363    }
364}