Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
2 / 2
CRAP
100.00% covered (success)
100.00%
1 / 1
PruneNotificationHistory
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
2 / 2
7
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 run
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
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\BackgroundJob;
10
11use OCA\Libresign\Db\NotificationAttemptMapper;
12use OCA\Libresign\Db\NotificationDeliveryMapper;
13use OCP\AppFramework\Db\TTransactional;
14use OCP\AppFramework\Utility\ITimeFactory;
15use OCP\BackgroundJob\IJob;
16use OCP\BackgroundJob\TimedJob;
17use OCP\IConfig;
18use OCP\IDBConnection;
19
20/**
21 * Reconcile abandoned attempts and optionally redact old notification evidence.
22 * This job does not send or retry messages; an ambiguous SMTP result stays unknown.
23 */
24final class PruneNotificationHistory extends TimedJob {
25    use TTransactional;
26    private const BATCH_SIZE = 500;
27
28    public function __construct(
29        ITimeFactory $time,
30        private NotificationAttemptMapper $attempts,
31        private NotificationDeliveryMapper $deliveries,
32        private IDBConnection $db,
33        private IConfig $config,
34    ) {
35        parent::__construct($time);
36        $this->setInterval(86400);
37        $this->setTimeSensitivity(IJob::TIME_INSENSITIVE);
38    }
39
40    #[\Override]
41    public function run($argument): void {
42        $now = new \DateTime('now', new \DateTimeZone('UTC'));
43
44        // The absence of a process after a full day cannot establish whether
45        // any transport accepted the message. Reconcile to UNKNOWN, never retry.
46        $interruptedBefore = (clone $now)->modify('-1 day');
47        foreach ($this->deliveries->findStaleSending($interruptedBefore) as $delivery) {
48            $this->atomic(function () use ($delivery, $interruptedBefore): void {
49                if ($this->deliveries->markStaleUnknown($delivery->getId(), $interruptedBefore)) {
50                    $this->attempts->finishInterrupted($delivery->getId());
51                }
52            }, $this->db);
53        }
54
55        // There is no implicit time-based deletion of notification evidence.
56        // A configured limit removes old attempt details and redacts personal
57        // fields, preserving permanent event-id tombstones for idempotency.
58        $retentionDays = (int)$this->config->getAppValue('libresign', 'notification_history_retention_days', '0');
59        if ($retentionDays <= 0) {
60            return;
61        }
62        $cutoff = (clone $now)->modify('-' . $retentionDays . ' days');
63
64        // Bound the job runtime to keep background jobs responsive.
65        $deadline = microtime(true) + 10;
66        do {
67            $count = $this->attempts->deleteOlderThan($cutoff, self::BATCH_SIZE);
68        } while ($count === self::BATCH_SIZE && microtime(true) < $deadline);
69
70        do {
71            $count = $this->deliveries->redactOlderThan($cutoff, self::BATCH_SIZE);
72        } while ($count === self::BATCH_SIZE && microtime(true) < $deadline);
73    }
74}