Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@
* helper?: bool,
* parentPolicyKey?: string,
* compositeChildren?: list<string>,
* mailProviderAvailable?: bool,
* }
* @psalm-type LibresignEffectivePolicyState = array{
* policyKey: string,
Expand Down
137 changes: 125 additions & 12 deletions lib/Service/MailService.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,19 @@
use OCA\Libresign\Db\FileMapper;
use OCA\Libresign\Db\SignRequest;
use OCA\Libresign\Exception\LibresignException;
use OCA\Libresign\Service\Policy\PolicyService;
use OCA\Libresign\Service\Policy\Provider\MailSenderStrategy\MailSenderStrategyPolicy;
use OCP\IAppConfig;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Mail\IEMailTemplate;
use OCP\Mail\IMailer;
use OCP\Mail\Provider\Address;
use OCP\Mail\Provider\IManager as IMailProviderManager;
use OCP\Mail\Provider\IMessageSend;
use OCP\Mail\Provider\IService;
use Psr\Log\LoggerInterface;

class MailService {
Expand All @@ -29,6 +38,9 @@ public function __construct(
private IL10N $l10n,
private IURLGenerator $urlGenerator,
private IAppConfig $appConfig,
private PolicyService $policyService,
private IMailProviderManager $mailProviderManager,
private IUserManager $userManager,
) {
}

Expand Down Expand Up @@ -67,15 +79,8 @@ public function notifySignDataUpdated(SignRequest $data, string $email, ?string
$this->l10n->t('Sign "%s"', [$file->getName()]),
$link
);
$message = $this->mailer->createMessage();
if ($data->getDisplayName()) {
$message->setTo([$email => $data->getDisplayName()]);
} else {
$message->setTo([$email]);
}
$message->useTemplate($emailTemplate);
try {
$this->mailer->send($message);
$this->sendSignRequestNotification($emailTemplate, $data, $email);
} catch (\Exception $e) {
$this->logger->error('Notify changes in unsigned notification mail could not be sent: ' . $e->getMessage());
throw new LibresignException('Notify unsigned notification mail could not be sent', 1);
Expand Down Expand Up @@ -107,19 +112,127 @@ public function notifyUnsignedUser(SignRequest $data, string $email, ?string $de
$this->l10n->t('Sign "%s"', [$file->getName()]),
$link
);
try {
$this->sendSignRequestNotification($emailTemplate, $data, $email);
} catch (\Exception $e) {
$this->logger->error('Notify unsigned notification mail could not be sent: ' . $e->getMessage());
throw new LibresignException('Notify unsigned notification mail could not be sent', 1);
}
}

/**
* Sends a signature request notification using the strategy configured in
* the mail_sender_strategy policy, falling back to the system mailer when
* the requester mail account cannot be used.
*/
private function sendSignRequestNotification(IEMailTemplate $emailTemplate, SignRequest $data, string $email): void {
$requesterId = $this->getFileById($data->getFileId())->getUserId();
if ($this->resolveMailSenderStrategy($requesterId) !== MailSenderStrategyPolicy::STRATEGY_REQUESTER) {
$this->sendAsSystem($emailTemplate, $data, $email);
return;
}

$requester = $requesterId !== '' ? $this->userManager->get($requesterId) : null;
if ($this->sendAsRequester($emailTemplate, $data, $email, $requesterId, $requester)) {
return;
}
// Keep replies going to the person who requested the signature even
// when the message could not leave their own mail account.
$this->sendAsSystem($emailTemplate, $data, $email, $this->replyToAddress($requester));
}

/**
* @return array<string, string>|null
*/
private function replyToAddress(?IUser $requester): ?array {
if (!$requester instanceof IUser) {
return null;
}
$address = (string)$requester->getEMailAddress();
if ($address === '') {
return null;
}
return [$address => $requester->getDisplayName()];
}

private function resolveMailSenderStrategy(string $requesterId): string {
return (string)$this->policyService
->resolveForUserId(MailSenderStrategyPolicy::KEY, $requesterId !== '' ? $requesterId : null)
->getEffectiveValue();
}

/**
* @param array<string, string>|null $replyTo
*/
private function sendAsSystem(IEMailTemplate $emailTemplate, SignRequest $data, string $email, ?array $replyTo = null): void {
$message = $this->mailer->createMessage();
if ($data->getDisplayName()) {
$message->setTo([$email => $data->getDisplayName()]);
} else {
$message->setTo([$email]);
}
if ($replyTo !== null) {
$message->setReplyTo($replyTo);
}
$message->useTemplate($emailTemplate);
$this->mailer->send($message);
}

/**
* @return bool true when the message was sent through the requester mail account
*/
private function sendAsRequester(IEMailTemplate $emailTemplate, SignRequest $data, string $email, string $requesterId, ?IUser $requester): bool {
try {
$this->mailer->send($message);
} catch (\Exception $e) {
$this->logger->error('Notify unsigned notification mail could not be sent: ' . $e->getMessage());
throw new LibresignException('Notify unsigned notification mail could not be sent', 1);
$service = $this->findRequesterMailService($requesterId, $requester);
if ($service === null) {
return false;
}
$message = $service->initiateMessage()
->setFrom($service->getPrimaryAddress())
->setTo(new Address($email, $data->getDisplayName() ?: null))
->setSubject($emailTemplate->renderSubject())
->setBodyHtml($emailTemplate->renderHtml())
->setBodyPlain($emailTemplate->renderText());
$service->sendMessage($message);
return true;
} catch (\Throwable $e) {
$this->logger->warning('Unable to send the notification through the requester mail account, falling back to the system mailer.', [
'requester' => $requesterId,
'exception' => $e,
]);
return false;
}
}

private function findRequesterMailService(string $requesterId, ?IUser $requester): (IService&IMessageSend)|null {
if ($requesterId === '' || !$this->mailProviderManager->has()) {
$this->logger->info('No mail provider is available to send the notification as the requester, falling back to the system mailer.', [
'requester' => $requesterId,
]);
return null;
}
if (!$requester instanceof IUser) {
$this->logger->info('Requester account not found, falling back to the system mailer.', [
'requester' => $requesterId,
]);
return null;
}
$address = (string)$requester->getEMailAddress();
$service = $address !== '' ? $this->mailProviderManager->findServiceByAddress($requesterId, $address) : null;
if ($service instanceof IMessageSend) {
return $service;
}
foreach ($this->mailProviderManager->services($requesterId) as $providerServices) {
foreach ($providerServices as $candidate) {
if ($candidate instanceof IMessageSend) {
return $candidate;
}
}
}
$this->logger->info('Requester has no mail account able to send messages, falling back to the system mailer.', [
'requester' => $requesterId,
]);
return null;
}

public function notifySignedUser(SignRequest $signRequest, string $email, File $libreSignFile, string $displayName): void {
Expand Down
12 changes: 12 additions & 0 deletions lib/Service/Policy/Contract/IPolicyDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ public function normalizeValue(mixed $rawValue): mixed;

public function validateValue(mixed $value, PolicyContext $context): void;

/**
* Validate a value that is about to be persisted (system, group or user
* scope). Runs the regular validateValue() checks plus any constraint that
* only makes sense at configuration time, such as requiring an external
* capability to be available. Runtime resolution keeps using
* validateValue() so already stored values are not silently discarded
* when the environment changes later.
*
* @throws \InvalidArgumentException when the value must not be saved
*/
public function validateValueForPersistence(mixed $value, PolicyContext $context): void;

/** @return list<mixed> */
public function allowedValues(PolicyContext $context): array;

Expand Down
13 changes: 13 additions & 0 deletions lib/Service/Policy/Model/PolicySpec.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ final class PolicySpec implements IPolicyDefinition {
private ?Closure $normalizer;
/** @var Closure(mixed, PolicyContext): void|null */
private ?Closure $validator;
/** @var Closure(mixed, PolicyContext): void|null */
private ?Closure $persistenceValidator;
/** @var array<string, mixed>|Closure(PolicyContext): array<string, mixed> */
private array|Closure $resolvedStateMetaResolver;
/** @var Closure(PolicyContext, ?PolicyLayer): bool|null */
Expand All @@ -41,6 +43,7 @@ final class PolicySpec implements IPolicyDefinition {
* @param list<mixed>|Closure(PolicyContext): list<mixed> $allowedValues
* @param Closure(mixed): mixed|null $normalizer
* @param Closure(mixed, PolicyContext): void|null $validator
* @param Closure(mixed, PolicyContext): void|null $persistenceValidator Extra checks applied only when a value is saved
* @param array<string, mixed>|Closure(PolicyContext): array<string, mixed> $resolvedStateMeta
* @param Closure(mixed, mixed, PolicyContext): void|null $delegatedValueValidator
* @param Closure(PolicyContext, ?PolicyLayer): bool|null $visibleGroupCountFilter
Expand Down Expand Up @@ -69,10 +72,12 @@ public function __construct(
private bool $helper = false,
private ?string $parentPolicyKey = null,
private array $compositeChildren = [],
?Closure $persistenceValidator = null,
) {
$this->allowedValuesResolver = $allowedValues;
$this->normalizer = $normalizer;
$this->validator = $validator;
$this->persistenceValidator = $persistenceValidator;
$this->resolvedStateMetaResolver = $resolvedStateMeta;
$this->visibleGroupCountFilterResolver = $visibleGroupCountFilter;
$this->groupPolicyManagerResolver = $groupPolicyManager;
Expand Down Expand Up @@ -158,6 +163,14 @@ public function validateValue(mixed $value, PolicyContext $context): void {
}
}

#[\Override]
public function validateValueForPersistence(mixed $value, PolicyContext $context): void {
$this->validateValue($value, $context);
if ($this->persistenceValidator !== null) {
($this->persistenceValidator)($value, $context);
}
}

#[\Override]
public function allowedValues(PolicyContext $context): array {
if ($this->allowedValuesResolver instanceof Closure) {
Expand Down
8 changes: 4 additions & 4 deletions lib/Service/Policy/PolicyService.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ public function saveSystem(string|\BackedEnum $policyKey, mixed $value, bool $al
? $definition->normalizeValue($definition->defaultSystemValue())
: $definition->normalizeValue($value);

$definition->validateValue($normalizedValue, $context);
$definition->validateValueForPersistence($normalizedValue, $context);
$this->source->saveSystemPolicy($definition->key(), $normalizedValue, $allowChildOverride);

return $this->resolver->resolve(
Expand Down Expand Up @@ -233,7 +233,7 @@ public function saveGroupPolicy(string|\BackedEnum $policyKey, string $groupId,
$this->assertCurrentActorCanManageGroupPolicy($definition->key(), $context);
$this->assertCurrentActorCanEditGroupPolicy($definition->key(), $groupId, $context);
$normalizedValue = $definition->normalizeValue($value);
$definition->validateValue($normalizedValue, $context);
$definition->validateValueForPersistence($normalizedValue, $context);
$createdBySystemAdmin = $context->getActorRole()->canManageSystemPolicies;
$this->source->saveGroupPolicy(
$definition->key(),
Expand Down Expand Up @@ -419,7 +419,7 @@ public function saveUserPreference(string|\BackedEnum $policyKey, mixed $value):
$definition = $this->registry->get($policyKey);
$this->assertScopeSupported($definition, PolicySpec::SCOPE_USER);
$normalizedValue = $definition->normalizeValue($value);
$definition->validateValue($normalizedValue, $context);
$definition->validateValueForPersistence($normalizedValue, $context);
$resolved = $this->resolver->resolve($definition, $context);
if (!$resolved->canSaveAsUserDefault()) {
// TRANSLATORS Error shown when saving a user preference for a policy that does not allow personal overrides. {policyKey} is the policy identifier.
Expand Down Expand Up @@ -447,7 +447,7 @@ public function saveUserPolicyForUserId(string|\BackedEnum $policyKey, string $u
$definition = $this->registry->get($policyKey);
$this->assertScopeSupported($definition, PolicySpec::SCOPE_USER);
$normalizedValue = $definition->normalizeValue($value);
$definition->validateValue($normalizedValue, $context);
$definition->validateValueForPersistence($normalizedValue, $context);
$this->source->saveUserPolicy($definition->key(), $context, $normalizedValue, $allowChildOverride);

return $this->source->loadUserPolicy($definition->key(), $context)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Service\Policy\Provider\MailSenderStrategy;

use OCA\Libresign\Service\Policy\Contract\IPolicyDefinition;
use OCA\Libresign\Service\Policy\Contract\IPolicyDefinitionProvider;
use OCA\Libresign\Service\Policy\Model\PolicySpec;
use OCA\Libresign\Service\Policy\Provider\Helper\PolicyKeyNormalizer;
use OCP\Mail\Provider\IManager as IMailProviderManager;

/**
* Controls which mail account LibreSign uses to send signature request
* notifications: the system mailer or the account of the requester.
*/
final class MailSenderStrategyPolicy implements IPolicyDefinitionProvider {
public const KEY = 'mail_sender_strategy';
public const SYSTEM_APP_CONFIG_KEY = self::KEY;

public const STRATEGY_SYSTEM = 'system';
public const STRATEGY_REQUESTER = 'requester';

private const STRATEGIES = [
self::STRATEGY_SYSTEM,
self::STRATEGY_REQUESTER,
];

public function __construct(
private IMailProviderManager $mailProviderManager,
) {
}

#[\Override]
public function keys(): array {
return [
self::KEY,
];
}

#[\Override]
public function get(string|\BackedEnum $policyKey): IPolicyDefinition {
return match (PolicyKeyNormalizer::normalize($policyKey)) {
self::KEY => new PolicySpec(
key: self::KEY,
defaultSystemValue: self::STRATEGY_SYSTEM,
allowedValues: self::STRATEGIES,
normalizer: static fn (mixed $rawValue): string => strtolower(trim((string)$rawValue)),
validator: static function (mixed $value): void {
if (!is_string($value) || !in_array($value, self::STRATEGIES, true)) {
throw new \InvalidArgumentException('Invalid value for ' . self::KEY);
}
},
appConfigKey: self::SYSTEM_APP_CONFIG_KEY,
supportsUserPreference: false,
resolvedStateMeta: fn (): array => [
'mailProviderAvailable' => $this->mailProviderManager->has(),
],
supportedScopes: [PolicySpec::SCOPE_SYSTEM],
// The requester strategy can only be configured while a mail provider
// is available. Once stored, runtime resolution keeps the value and
// MailService falls back to the system mailer when the environment
// changes later (provider removed, account deleted, sending failure).
persistenceValidator: function (mixed $value): void {
if ($value === self::STRATEGY_REQUESTER && !$this->mailProviderManager->has()) {
throw new \InvalidArgumentException('The requester strategy requires an available mail provider');
}
},
),
default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)),
};
}
}
2 changes: 2 additions & 0 deletions lib/Service/Policy/Provider/PolicyProviders.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use OCA\Libresign\Service\Policy\Provider\IdentificationDocuments\IdentificationDocumentsPolicy;
use OCA\Libresign\Service\Policy\Provider\IdentifyMethods\IdentifyMethodsPolicy;
use OCA\Libresign\Service\Policy\Provider\LegalInformation\LegalInformationPolicy;
use OCA\Libresign\Service\Policy\Provider\MailSenderStrategy\MailSenderStrategyPolicy;
use OCA\Libresign\Service\Policy\Provider\Reminder\ReminderPolicy;
use OCA\Libresign\Service\Policy\Provider\RequestSignGroups\RequestSignGroupsPolicy;
use OCA\Libresign\Service\Policy\Provider\Signature\SignatureFlowPolicy;
Expand Down Expand Up @@ -47,6 +48,7 @@ final class PolicyProviders {
ReminderPolicy::KEY => ReminderPolicy::class,
DefaultUserFolderPolicy::KEY => DefaultUserFolderPolicy::class,
LegalInformationPolicy::KEY => LegalInformationPolicy::class,
MailSenderStrategyPolicy::KEY => MailSenderStrategyPolicy::class,
SignatureHashAlgorithmPolicy::KEY => SignatureHashAlgorithmPolicy::class,
ValidationAccessPolicy::KEY => ValidationAccessPolicy::class,
SignatureFlowPolicy::KEY => SignatureFlowPolicy::class,
Expand Down
3 changes: 3 additions & 0 deletions openapi-administration.json
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,9 @@
"items": {
"type": "string"
}
},
"mailProviderAvailable": {
"type": "boolean"
}
}
},
Expand Down
Loading
Loading