Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.65% covered (success)
95.65%
44 / 46
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
NotificationSubmissionService
95.65% covered (success)
95.65%
44 / 46
50.00% covered (danger)
50.00%
1 / 2
10
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 submit
95.56% covered (success)
95.56%
43 / 45
0.00% covered (danger)
0.00%
0 / 1
9
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service\Notification;
10
11use OCA\Libresign\Db\NotificationAttemptMapper;
12use OCA\Libresign\Db\NotificationDelivery;
13use OCA\Libresign\Db\NotificationDeliveryMapper;
14use OCA\Libresign\Enum\NotificationDeliveryState;
15use OCA\Libresign\Exception\NotificationPreparationException;
16use OCP\AppFramework\Db\TTransactional;
17use OCP\IDBConnection;
18use Psr\Log\LoggerInterface;
19
20/** Manages one attempt. The claim commits before any external side effect. */
21class NotificationSubmissionService {
22    private const MAX_DELAY_SECONDS = 3600;
23    use TTransactional;
24
25    public function __construct(
26        private NotificationDeliveryMapper $deliveries,
27        private NotificationAttemptMapper $attempts,
28        private IDBConnection $db,
29        private NotificationFailureClassifier $failureClassifier,
30        private NotificationHistoryService $history,
31        private LoggerInterface $logger,
32    ) {
33    }
34
35    public function submit(NotificationDelivery $delivery, NotificationMessage $message, NotificationChannel $channel): bool {
36        $attempt = $this->atomic(function () use ($delivery) {
37            if (!$this->deliveries->claim($delivery->getId())) {
38                return null;
39            }
40            return $this->attempts->begin($delivery->getId());
41        }, $this->db);
42        if ($attempt === null) {
43            return false;
44        }
45
46        $outcome = NotificationDeliveryState::UNKNOWN;
47        $reason = 'unexpected_error';
48        $retrySafe = false;
49        try {
50            $channel->deliver($message);
51            $outcome = NotificationDeliveryState::ACCEPTED;
52            $reason = null;
53            // This legacy projection is not allowed to turn transport acceptance
54            // into an uncertain send or an invitation to retry SMTP.
55            try {
56                $this->history->recordAccepted($message);
57            } catch (\Exception $error) {
58                $reason = 'legacy_history_write_failed';
59                $this->logger->warning('Notification accepted but legacy history could not be updated', [
60                    'deliveryId' => $delivery->getId(),
61                    'channel' => $message->channel,
62                    'errorType' => get_debug_type($error),
63                ]);
64            }
65        } catch (\Exception $e) {
66            [$outcome, $reason] = $this->failureClassifier->classify($e);
67            // Never treat a nested preparation error in an ambiguous transport failure as proof of non-submission.
68            $retrySafe = $e instanceof NotificationPreparationException;
69            $this->logger->warning('Notification transport was not confirmed', [
70                'deliveryId' => $delivery->getId(),
71                'channel' => $message->channel,
72                'outcome' => $outcome->value,
73                'reasonCode' => $reason,
74            ]);
75            throw $e;
76        } finally {
77            $this->atomic(function () use ($delivery, $attempt, &$outcome, &$reason, &$retrySafe): void {
78                $retryAt = null;
79                if ($retrySafe && $outcome === NotificationDeliveryState::TEMPORARY_FAILURE) {
80                    $count = $this->deliveries->findById($delivery->getId())->getAttemptCount();
81                    if ($count > 0 && $count < NotificationDeliveryMapper::MAX_ATTEMPTS) {
82                        $base = min(self::MAX_DELAY_SECONDS, 30 * (2 ** ($count - 1)));
83                        $retryAt = new \DateTime('now', new \DateTimeZone('UTC'));
84                        $retryAt->modify('+' . ($base + random_int(0, min(30, $base))) . ' seconds');
85                    } else {
86                        $outcome = NotificationDeliveryState::FAILED;
87                        $reason = 'retry_limit_exhausted';
88                    }
89                }
90                $this->attempts->finish($attempt->getId(), $outcome, $reason);
91                $this->deliveries->finish($delivery->getId(), $outcome, $retryAt);
92            }, $this->db);
93        }
94        return true;
95    }
96}