Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.12% covered (success)
94.12%
32 / 34
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
NotificationDeliveryService
94.12% covered (success)
94.12%
32 / 34
33.33% covered (danger)
33.33%
1 / 3
17.06
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
 dispatch
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
8.02
 flushCommitted
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
8.01
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;
10
11use OCA\Libresign\BackgroundJob\DeliverNotification;
12use OCA\Libresign\Db\NotificationDelivery;
13use OCA\Libresign\Db\NotificationDeliveryMapper;
14use OCA\Libresign\Service\Notification\NotificationChannel;
15use OCA\Libresign\Service\Notification\NotificationDeliveryModeResolver;
16use OCA\Libresign\Service\Notification\NotificationDeliveryRegistrar;
17use OCA\Libresign\Service\Notification\NotificationEnvelopeCodec;
18use OCA\Libresign\Service\Notification\NotificationMessage;
19use OCA\Libresign\Service\Notification\NotificationSubmissionService;
20use OCA\Libresign\Service\Worker\WorkerActivationService;
21use OCP\BackgroundJob\IJobList;
22use OCP\IDBConnection;
23
24/**
25 * Register an intent first. The domain transaction owns the outbox row and
26 * queued job atomically. Never contact a provider before it commits.
27 */
28class NotificationDeliveryService {
29    /** @var array<int, array{NotificationDelivery, NotificationMessage, NotificationChannel}> */
30    private array $afterCommit = [];
31
32    /** @var array<int, string> Delivery ID => event key, checked after commit. */
33    private array $notificationWakes = [];
34
35    public function __construct(
36        private NotificationDeliveryRegistrar $registrar,
37        private NotificationSubmissionService $submission,
38        private NotificationEnvelopeCodec $codec,
39        private NotificationDeliveryModeResolver $mode,
40        private IJobList $jobs,
41        private IDBConnection $db,
42        private NotificationDeliveryMapper $deliveries,
43        private WorkerActivationService $workerActivationService,
44    ) {
45    }
46
47    /** @return bool Whether this invocation registered or submitted a delivery. */
48    public function dispatch(NotificationMessage $message, NotificationChannel $channel): bool {
49        try {
50            $delivery = $this->registrar->register($message, $channel, $this->codec->encode($message));
51            $async = $this->mode->isAsync();
52            $inTransaction = $this->db->inTransaction();
53            if ($inTransaction || $async) {
54                // Job insertion uses the same database connection and rolls back with
55                // a failed signer mutation. The timed recovery job is a second path.
56                $this->jobs->add(DeliverNotification::class, ['id' => $delivery->getId()]);
57                if (!$async) {
58                    $this->afterCommit[$delivery->getId()] = [$delivery, $message, $channel];
59                } elseif ($inTransaction) {
60                    // Never start a worker against an uncommitted outbox row.
61                    $this->notificationWakes[$delivery->getId()] = $delivery->getEventKey();
62                } else {
63                    $this->workerActivationService->ensureNotificationWorkerRunning();
64                }
65                return true;
66            }
67        } catch (\Exception $error) {
68            if ($this->db->inTransaction() && !$error instanceof \LogicException) {
69                throw new \LogicException('Failed to persist notification intent with the signing request', 0, $error);
70            }
71            throw $error;
72        }
73        // The compatibility path remains synchronous outside domain transactions.
74        // A transport failure cannot undo the already committed business event.
75        return $this->submission->submit($delivery, $message, $channel);
76    }
77    /**
78     * Preserve synchronous delivery after, never inside, domain transactions.
79     * A request that committed must succeed even if SMTP fails afterwards.
80     */
81    public function flushCommitted(): void {
82        if ($this->db->inTransaction()) {
83            return;
84        }
85        // Only wake for an event that survived the business transaction.
86        // A rollback may leave a stale in-memory ID, or an ID may be reused.
87        $wakes = $this->notificationWakes;
88        $this->notificationWakes = [];
89        foreach ($wakes as $id => $eventKey) {
90            try {
91                if ($this->deliveries->findById($id)->getEventKey() === $eventKey) {
92                    $this->workerActivationService->ensureNotificationWorkerRunning();
93                    break;
94                }
95            } catch (\Exception) {
96                // A missing or rolled-back intent must not trigger early execution.
97            }
98        }
99
100        $buffer = $this->afterCommit;
101        $this->afterCommit = [];
102        foreach ($buffer as [$delivery, $message, $channel]) {
103            try {
104                // A rolled-back transaction can reuse an auto-increment ID. Never
105                // submit its former message for a new event with the same ID.
106                $current = $this->deliveries->findById($delivery->getId());
107                if ($current->getEventKey() !== $delivery->getEventKey()) {
108                    continue;
109                }
110                $this->submission->submit($delivery, $message, $channel);
111            } catch (\Exception) {
112                // Submission tracks and logs the outcome. The domain already committed.
113            }
114        }
115    }
116
117}