Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.10% covered (warning)
76.10%
156 / 205
58.82% covered (warning)
58.82%
10 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
MailService
76.10% covered (warning)
76.10%
156 / 205
58.82% covered (warning)
58.82%
10 / 17
86.52
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
 getFileById
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 notifySignDataUpdated
79.17% covered (warning)
79.17%
19 / 24
0.00% covered (danger)
0.00%
0 / 1
3.08
 notifyUnsignedUser
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
3.01
 applyParticipantNotificationHeader
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 applyParticipantNotificationAction
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 sendSignRequestNotification
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 replyToAddress
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 resolveMailSenderStrategy
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 sendAsSystem
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 sendAndCheck
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 sendAsRequester
75.00% covered (warning)
75.00%
18 / 24
0.00% covered (danger)
0.00%
0 / 1
6.56
 findRequesterMailService
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
 notifySignedUser
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 notifyCanceledRequest
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 buildValidationLink
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 sendCodeToSign
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service;
10
11use OCA\Libresign\Db\File;
12use OCA\Libresign\Db\FileMapper;
13use OCA\Libresign\Db\SignRequest;
14use OCA\Libresign\Enum\NotificationDeliveryState;
15use OCA\Libresign\Exception\LibresignException;
16use OCA\Libresign\Exception\MailTransportException;
17use OCA\Libresign\Service\Policy\PolicyService;
18use OCA\Libresign\Service\Policy\Provider\MailSenderStrategy\MailSenderStrategyPolicy;
19use OCP\IAppConfig;
20use OCP\IL10N;
21use OCP\IURLGenerator;
22use OCP\IUser;
23use OCP\IUserManager;
24use OCP\Mail\IEMailTemplate;
25use OCP\Mail\IMailer;
26use OCP\Mail\IMessage;
27use OCP\Mail\Provider\Address;
28use OCP\Mail\Provider\IManager as IMailProviderManager;
29use OCP\Mail\Provider\IMessageSend;
30use OCP\Mail\Provider\IService;
31use Psr\Log\LoggerInterface;
32
33class MailService {
34    /** @var array */
35    private $files = [];
36
37    public function __construct(
38        private LoggerInterface $logger,
39        private IMailer $mailer,
40        private FileMapper $fileMapper,
41        private IL10N $l10n,
42        private IURLGenerator $urlGenerator,
43        private IAppConfig $appConfig,
44        private PolicyService $policyService,
45        private IMailProviderManager $mailProviderManager,
46        private IUserManager $userManager,
47    ) {
48    }
49
50    /**
51     * @psalm-suppress MixedReturnStatement
52     */
53    private function getFileById(int $fileId): File {
54        if (!isset($this->files[$fileId])) {
55            $this->files[$fileId] = $this->fileMapper->getById($fileId);
56        }
57        return $this->files[$fileId];
58    }
59
60    /**
61     * @psalm-suppress MixedMethodCall
62     */
63    public function notifySignDataUpdated(SignRequest $data, string $email, ?string $description = null): void {
64        $file = $this->getFileById($data->getFileId());
65        $isObserver = $data->isObserver();
66
67        $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail');
68        $this->applyParticipantNotificationHeader(
69            $emailTemplate,
70            $isObserver,
71            // TRANSLATORS Email subject notifying an observer that a document changed and should be reviewed again.
72            $this->l10n->t('LibreSign: Changes were made to a document'),
73            // TRANSLATORS Email subject notifying a signer that a pending signature request changed and should be reviewed again.
74            $this->l10n->t('LibreSign: Changes were made to a document waiting for your signature'),
75        );
76
77        if (!empty($description)) {
78            $emailTemplate->addBodyText($description);
79            $emailTemplate->addBodyText('');
80        }
81
82        $this->applyParticipantNotificationAction(
83            $emailTemplate,
84            $data,
85            $file,
86            $isObserver,
87            // TRANSLATORS Email body telling an observer to reopen the document because some request details changed.
88            $this->l10n->t('Changes were made to a document. Open the link below:'),
89            // TRANSLATORS Email body telling the signer to reopen the request because some request details changed.
90            $this->l10n->t('Changes were made to a document you need to sign. Open the link below:'),
91        );
92        try {
93            $this->sendSignRequestNotification($emailTemplate, $data, $email);
94        } catch (\Exception $e) {
95            $this->logger->error('Notify changes in unsigned notification mail could not be sent');
96            throw new LibresignException('Notify unsigned notification mail could not be sent', 1, $e);
97        }
98    }
99
100    /**
101     * @psalm-suppress MixedMethodCall
102     */
103    public function notifyUnsignedUser(SignRequest $data, string $email, ?string $description = null): void {
104        $file = $this->getFileById($data->getFileId());
105        $isObserver = $data->isObserver();
106
107        $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail');
108        $this->applyParticipantNotificationHeader(
109            $emailTemplate,
110            $isObserver,
111            // TRANSLATORS Email subject notifying an observer that a document is available to view.
112            $this->l10n->t('LibreSign: A document is ready to view'),
113            // TRANSLATORS Email subject notifying a signer that a document is ready for their digital signature.
114            $this->l10n->t('LibreSign: A document is ready for your signature'),
115        );
116
117        if (!empty($description)) {
118            $emailTemplate->addBodyText($description);
119            $emailTemplate->addBodyText('');
120        }
121
122        $this->applyParticipantNotificationAction(
123            $emailTemplate,
124            $data,
125            $file,
126            $isObserver,
127            // TRANSLATORS Email body inviting an observer to open the document and view it.
128            $this->l10n->t('A document is ready to view. Open the link below:'),
129            // TRANSLATORS Email body inviting the signer to open the document and sign it.
130            $this->l10n->t('A document is ready for your signature. Open the link below:'),
131        );
132        try {
133            $this->sendSignRequestNotification($emailTemplate, $data, $email);
134        } catch (\Exception $e) {
135            $this->logger->error('Notify unsigned notification mail could not be sent');
136            throw new LibresignException('Notify unsigned notification mail could not be sent', 1, $e);
137        }
138    }
139
140    private function applyParticipantNotificationHeader(
141        IEMailTemplate $emailTemplate,
142        bool $isObserver,
143        string $observerSubject,
144        string $signerSubject,
145    ): void {
146        $emailTemplate->setSubject($isObserver ? $observerSubject : $signerSubject);
147        $emailTemplate->addHeader();
148        $emailTemplate->addHeading(
149            $isObserver
150                // TRANSLATORS Email heading shown above a document available for viewing.
151                ? $this->l10n->t('Document to view')
152                // TRANSLATORS Email heading shown above a document awaiting the recipient's signature.
153                : $this->l10n->t('Document to sign'),
154            false,
155        );
156    }
157
158    private function applyParticipantNotificationAction(
159        IEMailTemplate $emailTemplate,
160        SignRequest $data,
161        File $file,
162        bool $isObserver,
163        string $observerBody,
164        string $signerBody,
165    ): void {
166        $emailTemplate->addBodyText($isObserver ? $observerBody : $signerBody);
167        if ($isObserver) {
168            $emailTemplate->addBodyButton(
169                // TRANSLATORS Email button label that opens the document validation view. %s is the document filename.
170                $this->l10n->t('View "%s"', [$file->getName()]),
171                $this->buildValidationLink($file),
172            );
173            return;
174        }
175
176        $emailTemplate->addBodyButton(
177            // TRANSLATORS Email button label that opens the signing page. %s is the document filename.
178            $this->l10n->t('Sign "%s"', [$file->getName()]),
179            $this->urlGenerator->linkToRouteAbsolute('libresign.page.sign', ['uuid' => $data->getUuid()]),
180        );
181    }
182
183    /**
184     * Sends a signature request notification using the strategy configured in
185     * the mail_sender_strategy policy, falling back to the system mailer when
186     * the requester mail account cannot be used.
187     */
188    private function sendSignRequestNotification(IEMailTemplate $emailTemplate, SignRequest $data, string $email): void {
189        $requesterId = $this->getFileById($data->getFileId())->getUserId();
190        if ($this->resolveMailSenderStrategy($requesterId) !== MailSenderStrategyPolicy::STRATEGY_REQUESTER) {
191            $this->sendAsSystem($emailTemplate, $data, $email);
192            return;
193        }
194
195        $requester = $requesterId !== '' ? $this->userManager->get($requesterId) : null;
196        if ($this->sendAsRequester($emailTemplate, $data, $email, $requesterId, $requester)) {
197            return;
198        }
199        // Keep replies going to the person who requested the signature even
200        // when the message could not leave their own mail account.
201        $this->sendAsSystem($emailTemplate, $data, $email, $this->replyToAddress($requester));
202    }
203
204    /**
205     * @return array<string, string>|null
206     */
207    private function replyToAddress(?IUser $requester): ?array {
208        if (!$requester instanceof IUser) {
209            return null;
210        }
211        $address = (string)$requester->getEMailAddress();
212        if ($address === '') {
213            return null;
214        }
215        return [$address => $requester->getDisplayName()];
216    }
217
218    private function resolveMailSenderStrategy(string $requesterId): string {
219        return (string)$this->policyService
220            ->resolveForUserId(MailSenderStrategyPolicy::KEY, $requesterId !== '' ? $requesterId : null)
221            ->getEffectiveValue();
222    }
223
224    /**
225     * @param array<string, string>|null $replyTo
226     */
227    private function sendAsSystem(IEMailTemplate $emailTemplate, SignRequest $data, string $email, ?array $replyTo = null): void {
228        $message = $this->mailer->createMessage();
229        if ($data->getDisplayName()) {
230            $message->setTo([$email => $data->getDisplayName()]);
231        } else {
232            $message->setTo([$email]);
233        }
234        if ($replyTo !== null) {
235            $message->setReplyTo($replyTo);
236        }
237        $message->useTemplate($emailTemplate);
238        $this->sendAndCheck($message);
239    }
240
241    /**
242     * An empty failure list means transport acceptance, not inbox delivery.
243     * Any returned recipient failure must not be reported as success.
244     */
245    private function sendAndCheck(IMessage $message): void {
246        try {
247            $failed = $this->mailer->send($message);
248        } catch (\Exception $e) {
249            // A timeout can happen after SMTP DATA was accepted.
250            throw new MailTransportException(
251                'Mail transport returned no reliable submission result',
252                NotificationDeliveryState::UNKNOWN,
253                'smtp_outcome_unknown',
254                $e,
255            );
256        }
257        if ($failed !== []) {
258            throw new MailTransportException(
259                'Mail transport reported rejected recipients',
260                NotificationDeliveryState::FAILED,
261                'recipient_rejected_unclassified',
262            );
263        }
264    }
265    /**
266     * @return bool true when the message was sent through the requester mail account
267     */
268    private function sendAsRequester(IEMailTemplate $emailTemplate, SignRequest $data, string $email, string $requesterId, ?IUser $requester): bool {
269        // A missing or unusable account is safe to fall back from. A send
270        // exception is not: it might occur *after* the SMTP server accepted.
271        try {
272            $service = $this->findRequesterMailService($requesterId, $requester);
273        } catch (\Exception) {
274            // Account lookup has no transport side effect.
275            $this->logger->warning('Requester mail account lookup failed; using the system mailer');
276            return false;
277        }
278        if ($service === null) {
279            return false;
280        }
281        try {
282            $message = $service->initiateMessage()
283                ->setFrom($service->getPrimaryAddress())
284                ->setTo(new Address($email, $data->getDisplayName() ?: null))
285                ->setSubject($emailTemplate->renderSubject())
286                ->setBodyHtml($emailTemplate->renderHtml())
287                ->setBodyPlain($emailTemplate->renderText());
288        } catch (\Exception $e) {
289            $this->logger->warning('Requester mail could not be prepared; using the system mailer');
290            return false;
291        }
292        try {
293            $service->sendMessage($message);
294        } catch (\Exception $e) {
295            throw new MailTransportException(
296                'Requester mail submission outcome is unknown',
297                NotificationDeliveryState::UNKNOWN,
298                'requester_outcome_unknown',
299                $e,
300            );
301        }
302        return true;
303    }
304
305    private function findRequesterMailService(string $requesterId, ?IUser $requester): (IService&IMessageSend)|null {
306        if ($requesterId === '' || !$this->mailProviderManager->has()) {
307            $this->logger->info('No mail provider is available to send the notification as the requester, falling back to the system mailer.', [
308                'requester' => $requesterId,
309            ]);
310            return null;
311        }
312        if (!$requester instanceof IUser) {
313            $this->logger->info('Requester account not found, falling back to the system mailer.', [
314                'requester' => $requesterId,
315            ]);
316            return null;
317        }
318        $address = (string)$requester->getEMailAddress();
319        $service = $address !== '' ? $this->mailProviderManager->findServiceByAddress($requesterId, $address) : null;
320        if ($service instanceof IMessageSend) {
321            return $service;
322        }
323        $this->logger->info('No mail account matching the requester email address can send messages, falling back to the system mailer.', [
324            'requester' => $requesterId,
325        ]);
326        return null;
327    }
328
329    public function notifySignedUser(SignRequest $signRequest, string $email, File $libreSignFile, string $displayName): void {
330        $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail');
331        // TRANSLATORS Email subject sent to the requester after another signer successfully signs the document.
332        $emailTemplate->setSubject($this->l10n->t('LibreSign: A document has been signed'));
333        $emailTemplate->addHeader();
334        // TRANSLATORS Email heading shown after a document was completed by one signer.
335        $emailTemplate->addHeading($this->l10n->t('Signed document'), false);
336        // TRANSLATORS Email body confirming that a signer finished signing. %s is the display name of the signer who completed the document.
337        $emailTemplate->addBodyText($this->l10n->t('%s signed the document. You can access it using the link below:', [$signRequest->getDisplayName()]));
338        $link = $this->urlGenerator->linkToRouteAbsolute('libresign.page.indexFPath', [
339            'path' => 'validation/' . $libreSignFile->getUuid(),
340        ]);
341        $file = $this->getFileById($signRequest->getFileId());
342        $emailTemplate->addBodyButton(
343            // TRANSLATORS Email button label that opens the validation view of the signed document. %s is the document filename.
344            $this->l10n->t('View signed document "%s"', [$file->getName()]),
345            $link
346        );
347        $message = $this->mailer->createMessage();
348        $message->setTo([$email => $displayName]);
349
350        $message->useTemplate($emailTemplate);
351        try {
352            $this->sendAndCheck($message);
353        } catch (\Exception $e) {
354            $this->logger->error('Notify signed notification mail could not be sent');
355            throw new LibresignException('Notify signed notification mail could not be sent', 1, $e);
356        }
357    }
358
359    public function notifyCanceledRequest(SignRequest $signRequest, string $email, File $libreSignFile): void {
360        $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail');
361        // TRANSLATORS Email subject shown when the requester cancels a pending signature request.
362        $emailTemplate->setSubject($this->l10n->t('LibreSign: A signature request has been canceled'));
363        $emailTemplate->addHeader();
364        // TRANSLATORS Email heading shown when a signature request is no longer active.
365        $emailTemplate->addHeading($this->l10n->t('Signature request canceled'), false);
366        // TRANSLATORS Email body text shown after cancellation. %s is the document filename that no longer needs a signature.
367        $emailTemplate->addBodyText($this->l10n->t('The request for you to sign "%s" has been canceled.', [$libreSignFile->getName()]));
368        $message = $this->mailer->createMessage();
369        if ($signRequest->getDisplayName()) {
370            $message->setTo([$email => $signRequest->getDisplayName()]);
371        } else {
372            $message->setTo([$email]);
373        }
374        $message->useTemplate($emailTemplate);
375        try {
376            $this->sendAndCheck($message);
377        } catch (\Exception $e) {
378            $this->logger->error('Notify canceled request mail could not be sent');
379            throw new LibresignException('Notify canceled request mail could not be sent', 1, $e);
380        }
381    }
382
383    private function buildValidationLink(File $file): string {
384        return $this->urlGenerator->linkToRouteAbsolute('libresign.page.validationFilePublic', [
385            'uuid' => $file->getUuid(),
386        ]);
387    }
388
389    public function sendCodeToSign(string $email, string $name, string $code): void {
390        $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail');
391        // TRANSLATORS Email subject for a one-time verification code required to sign a document.
392        $emailTemplate->setSubject($this->l10n->t('LibreSign: Verification code to sign a document'));
393        $emailTemplate->addHeader();
394        // TRANSLATORS Email instruction introducing the one-time code used to complete a digital signature.
395        $emailTemplate->addBodyText($this->l10n->t('Use this code to sign the document:'));
396        $emailTemplate->addBodyText($code);
397        $message = $this->mailer->createMessage();
398        if (!empty($name)) {
399            $message->setTo([$email => $name]);
400        } else {
401            $message->setTo([$email]);
402        }
403        $message->useTemplate($emailTemplate);
404        try {
405            $this->mailer->send($message);
406        } catch (\Exception $e) {
407            $this->logger->error('Mail with code to sign document could not be sent: ' . $e->getMessage());
408            throw new LibresignException('Mail with code to sign document could not be sent', 1);
409        }
410    }
411}