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