Lines 55.96% 169 / 302
Methods 36.84% 7 / 19
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 10 / 10 100.00% 1 / 1 2
 initQueueTransmission 37.03% 20 / 54 0.00% 0 / 1 119.84
 sendQueueItems 73.07% 57 / 78 0.00% 0 / 1 14.81
 getValidMailer 100.00% 6 / 6 100.00% 1 / 1 6
 readMailer 71.11% 32 / 45 0.00% 0 / 1 12.41
 startProcess 0.00% 0 / 13 0.00% 0 / 1 6
 deleteEntitiesFromQueue 0.00% 0 / 9 0.00% 0 / 1 6
 [BO\Zmsmessaging\BaseController] getLogList 100.00% 1 / 1 100.00% 1 / 1 1
 [BO\Zmsmessaging\BaseController] clearLogList 0.00% 0 / 1 0.00% 0 / 1 2
 [BO\Zmsmessaging\BaseController] getSpendTime 100.00% 2 / 2 100.00% 1 / 1 1
 [BO\Zmsmessaging\BaseController] getNow 75.00% 3 / 4 0.00% 0 / 1 2.06
 [BO\Zmsmessaging\BaseController] getHttp 75.00% 3 / 4 0.00% 0 / 1 2.06
 [BO\Zmsmessaging\BaseController] sendMailer 100.00% 4 / 4 100.00% 1 / 1 6
 [BO\Zmsmessaging\BaseController] removeEntityOlderThanOneHour 100.00% 7 / 7 100.00% 1 / 1 2
 [BO\Zmsmessaging\BaseController] deleteEntityFromQueue 100.00% 6 / 6 100.00% 1 / 1 2
 [BO\Zmsmessaging\BaseController] testEntity 36.36% 8 / 22 0.00% 0 / 1 19.63
 [BO\Zmsmessaging\BaseController] monitorProcesses 0.00% 0 / 19 0.00% 0 / 1 90
 [BO\Zmsmessaging\BaseController] log 90.90% 10 / 11 0.00% 0 / 1 2.00
 [BO\Zmsmessaging\BaseController] convertCollectionToArray 0.00% 0 / 6 0.00% 0 / 1 6
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}

Inherited from BO\Zmsmessaging\BaseController

32    public static function getLogList(): array
33    {
34        return static::$logList;
35    }
37    public static function clearLogList(): void
38    {
39        static::$logList = [];
40    }
42    protected function getSpendTime(): float
43    {
44        $time = round(microtime(true) - $this->startTime, 3);
45        return $time;
46    }
48    protected function getNow(): \DateTimeInterface
49    {
50        $now = \App::$now;
51        if ($now instanceof \DateTimeInterface) {
52            return $now;
53        }
54        throw new \RuntimeException('Current time is not initialized');
55    }
57    protected function getHttp(): Http
58    {
59        $http = \App::$http;
60        if ($http instanceof Http) {
61            return $http;
62        }
63        throw new \RuntimeException('HTTP client is not initialized');
64    }
66    protected function sendMailer(Entity $entity, mixed $mailer = null, mixed $action = false): mixed
67    {
68        // @codeCoverageIgnoreStart
69        $hasSendSuccess = ($action) ? $mailer->Send() : $action;
70        if (false !== $action && null !== $mailer && ! $hasSendSuccess) {
71            $this->log("Exception: SendingFailed  - " . $this->getNow()->format('c'));
72            throw new Exception\SendingFailed();
73        }
74        // @codeCoverageIgnoreEnd
75        $log = new Mimepart(['mime' => 'text/plain']);
76        $log['content'] = ($entity instanceof Mail) ? $entity['subject'] : $entity['message'];
77        $this->getHttp()->readPostResult('/log/process/' . $entity['process']['id'] . '/', $log);
78        return $mailer;
79    }
84    protected function removeEntityOlderThanOneHour(Mail $entity)
85    {
86        if (3600 < $this->getNow()->getTimestamp() - $entity['createTimestamp']) {
87            $this->deleteEntityFromQueue($entity);
88            $log = new Mimepart(['mime' => 'text/plain']);
89            $log['content'] = 'Zmsmessaging Failure: Queue entry older than 1 hour has been removed';
90            $this->getHttp()->readPostResult('/log/process/' . $entity['process']['id'] . '/', $log, ['error' => 1]);
91            \App::$log->warning($log['content']);
92            return false;
93        }
94    }
96    public function deleteEntityFromQueue(Mail $entity): bool
97    {
98        try {
99            $entity = $this->getHttp()
100                ->readDeleteResult('/mails/' . $entity['id'] . '/')
101                ->getEntity();
102        } catch (\BO\Zmsclient\Exception $exception) {
103            throw $exception;
104        }
105
106        return (bool) $entity;
107    }
112    public function testEntity(Mail $entity)
113    {
114        if (!isset($entity['department'])) {
115            throw new \Exception("Could not resolve department for message " . $entity['id']);
116        }
117        if (!isset($entity['department']['email'])) {
118            throw new \Exception(
119                "No mail address for department "
120                . $entity['department']['name']
121                . " (departmentID="
122                . $entity['department']['id']
123                . " Vorgang="
124                . $entity['process']['id']
125                . ") "
126                . $entity['id']
127            );
128        }
129        if (! $entity->hasContent()) {
130            throw new \BO\Zmsmessaging\Exception\MailWithoutContent();
131        }
132        $isMail = Validator::value($entity->getRecipient())->isMail()->getValue();
133        if (!$isMail) {
134            throw new \BO\Zmsmessaging\Exception\InvalidMailAddress();
135        }
136        if (\App::$verify_dns_enabled) {
137            $hasDns = Validator::value($entity->getRecipient())->isMail()->hasDNS()->getValue();
138            if (!$hasDns) {
139                throw new \BO\Zmsmessaging\Exception\InvalidMailAddress();
140            }
141        }
142    }
144    protected function monitorProcesses(array $processHandles): void
145    {
146        $running = true;
147        while ($running) {
148            $running = false;
149            foreach ($processHandles as &$handle) {
150                if (is_resource($handle['process'])) {
151                    $status = proc_get_status($handle['process']);
152                    if ($status['running']) {
153                        $running = true;
154                    } else {
155                        $output = stream_get_contents($handle['pipes'][1]);  // stdout
156                        $errorOutput = stream_get_contents($handle['pipes'][2]);  // stderr
157                        fclose($handle['pipes'][1]);
158                        fclose($handle['pipes'][2]);
159                        if (is_string($output) && trim($output)) {
160                            $this->log("\nProcess stdout: " . trim($output) . "\n");
161                        }
162                        if (is_string($errorOutput) && trim($errorOutput)) {
163                            $this->log("\nProcess stderr: " . trim($errorOutput) . "\n");
164                        }
165
166                        proc_close($handle['process']);
167                        $handle['process'] = null;
168                    }
169                }
170            }
171            usleep(500000);
172        }
173    }
178    public function log(string $message): void
179    {
180        $time = $this->getSpendTime();
181        $memory = memory_get_usage() / (1024 * 1024);
182        static::$logList[] = $message;
183
184        $context = [
185            'component' => 'zmsmessaging',
186            'elapsed' => $time,
187            'memory_mb' => $memory,
188        ];
189        if ($this->verbose) {
190            \App::$log->debug($message, $context);
191        } else {
192            \App::$log->info($message, $context);
193        }
194    }
196    protected function convertCollectionToArray(mixed $collection): array
197    {
198        $this->log("Converting collection to array");
199        $array = [];
200        foreach ($collection as $item) {
201            $array[] = $item;
202        }
203        $this->log("Conversion complete, array size: " . count($array));
204        return $array;
205    }