diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index 55002e372..47c7ea782 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -5,6 +5,18 @@ # Changelog +## Unreleased + +- **Confirmation emails for respondents** + + Form owners can enable an automatic confirmation email that is sent to the respondent after a successful submission. + Requires an email-validated short text question in the form. + + Supported placeholders in subject/body: + + - `{formTitle}`, `{formDescription}` + - `{}` (question `name` or text, sanitized) + ## v5.2.0 - 2025-09-25 - **Time: restrictions and ranges** diff --git a/docs/API_v3.md b/docs/API_v3.md index 40c61d54c..888cff84c 100644 --- a/docs/API_v3.md +++ b/docs/API_v3.md @@ -175,6 +175,10 @@ Returns the full-depth object of the requested form (without submissions). "state": 0, "lockedBy": null, "lockedUntil": null, + "confirmationEmailEnabled": false, + "confirmationEmailSubject": null, + "confirmationEmailBody": null, + "confirmationEmailQuestionId": null, "permissions": [ "edit", "results", diff --git a/docs/DataStructure.md b/docs/DataStructure.md index a4dc03c64..cdd2f8477 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -13,31 +13,35 @@ This document describes the Object-Structure, that is used within the Forms App ### Form -| Property | Type | Restrictions | Description | -| -------------------- | ------------------------------------ | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| id | Integer | unique | An instance-wide unique id of the form | -| hash | 16-char String | unique | An instance-wide unique hash | -| title | String | max. 256 ch. | The form title | -| description | String | max. 8192 ch. | The Form description | -| ownerId | String | | The nextcloud userId of the form owner | -| submissionMessage | String | max. 2048 ch. | Optional custom message, with Markdown support, to be shown to users when the form is submitted (default is used if set to null) | -| created | unix timestamp | | When the form has been created | -| access | [Access-Object](#access-object) | | Describing access-settings of the form | -| expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ | -| isAnonymous | Boolean | | If Answers will be stored anonymously | -| state | Integer | [Form state](#form-state) | The state of the form | -| lockedBy | String | | The user ID for who has exclusive edit access at the moment | -| lockedUntil | unix timestamp | | When the form lock will expire | -| submitMultiple | Boolean | | If users are allowed to submit multiple times to the form | -| allowEditSubmissions | Boolean | | If users are allowed to edit or delete their response | -| showExpiration | Boolean | | If the expiration date will be shown on the form | -| canSubmit | Boolean | | If the user can Submit to the form, i.e. calculated information out of `submitMultiple` and existing submissions. | -| permissions | Array of [Permissions](#permissions) | Array of permissions regarding the form | -| questions | Array of [Questions](#question) | | Array of questions belonging to the form | -| shares | Array of [Shares](#share) | | Array of shares of the form | -| submissions | Array of [Submissions](#submission) | | Array of submissions belonging to the form | -| submissionCount | Integer | | Number of submissions to the form | -| submissionMessage | String | | Message to show after a submission | +| Property | Type | Restrictions | Description | +| --------------------------- | ------------------------------------ | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| id | Integer | unique | An instance-wide unique id of the form | +| hash | 16-char String | unique | An instance-wide unique hash | +| title | String | max. 256 ch. | The form title | +| description | String | max. 8192 ch. | The Form description | +| ownerId | String | | The nextcloud userId of the form owner | +| submissionMessage | String | max. 2048 ch. | Optional custom message, with Markdown support, to be shown to users when the form is submitted (default is used if set to null) | +| confirmationEmailEnabled | Boolean | | If enabled, send a confirmation email to the respondent after submission | +| confirmationEmailSubject | String | max. 255 ch. | Optional confirmation email subject template (supports placeholders) | +| confirmationEmailBody | String | | Optional confirmation email body template (plain text, supports placeholders) | +| confirmationEmailQuestionId | Integer | | The id of the question belonging to the form, that holds the email address of the respondent | +| created | unix timestamp | | When the form has been created | +| access | [Access-Object](#access-object) | | Describing access-settings of the form | +| expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ | +| isAnonymous | Boolean | | If Answers will be stored anonymously | +| state | Integer | [Form state](#form-state) | The state of the form | +| lockedBy | String | | The user ID for who has exclusive edit access at the moment | +| lockedUntil | unix timestamp | | When the form lock will expire | +| submitMultiple | Boolean | | If users are allowed to submit multiple times to the form | +| allowEditSubmissions | Boolean | | If users are allowed to edit or delete their response | +| showExpiration | Boolean | | If the expiration date will be shown on the form | +| canSubmit | Boolean | | If the user can Submit to the form, i.e. calculated information out of `submitMultiple` and existing submissions. | +| permissions | Array of [Permissions](#permissions) | Array of permissions regarding the form | +| questions | Array of [Questions](#question) | | Array of questions belonging to the form | +| shares | Array of [Shares](#share) | | Array of shares of the form | +| submissions | Array of [Submissions](#submission) | | Array of submissions belonging to the form | +| submissionCount | Integer | | Number of submissions to the form | +| submissionMessage | String | | Message to show after a submission | ``` { @@ -46,6 +50,10 @@ This document describes the Object-Structure, that is used within the Forms App "title": "Form 1", "description": "Description Text", "ownerId": "jonas", + "confirmationEmailEnabled": false, + "confirmationEmailSubject": null, + "confirmationEmailBody": null, + "confirmationEmailQuestionId": null, "created": 1611240961, "access": {}, "expires": 0, diff --git a/lib/BackgroundJob/SendConfirmationMailJob.php b/lib/BackgroundJob/SendConfirmationMailJob.php new file mode 100644 index 000000000..c6db53c94 --- /dev/null +++ b/lib/BackgroundJob/SendConfirmationMailJob.php @@ -0,0 +1,63 @@ +mailer->createEMailTemplate('forms.Confirmation'); + $emailTemplate->setSubject($subject); + $emailTemplate->addHeader(); + $emailTemplate->addHeading($subject); + $emailTemplate->addBodyText($body); + $emailTemplate->addFooter(); + + $message = $this->mailer->createMessage(); + $message->setFrom([Util::getDefaultEmailAddress('noreply') => $this->defaults->getName()]); + $message->setTo([$recipient]); + $message->useTemplate($emailTemplate); + $this->mailer->send($message); + $this->logger->debug('Confirmation email sent successfully', [ + 'formId' => $formId, + 'submissionId' => $submissionId, + ]); + } catch (\Exception $e) { + $this->logger->error('Error while sending confirmation email', [ + 'exception' => $e, + 'formId' => $formId, + 'submissionId' => $submissionId, + ]); + } + } +} diff --git a/lib/Constants.php b/lib/Constants.php index 0ff0341bd..fa0c347db 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -18,12 +18,16 @@ class Constants { public const CONFIG_KEY_ALLOWSHOWTOALL = 'allowShowToAll'; public const CONFIG_KEY_CREATIONALLOWEDGROUPS = 'creationAllowedGroups'; public const CONFIG_KEY_RESTRICTCREATION = 'restrictCreation'; + public const CONFIG_KEY_ALLOWCONFIRMATIONEMAIL = 'allowConfirmationEmail'; + public const CONFIG_KEY_CONFIRMATIONEMAILRATELIMIT = 'confirmationEmailRateLimit'; public const CONFIG_KEYS = [ self::CONFIG_KEY_ALLOWPERMITALL, self::CONFIG_KEY_ALLOWPUBLICLINK, self::CONFIG_KEY_ALLOWSHOWTOALL, self::CONFIG_KEY_CREATIONALLOWEDGROUPS, - self::CONFIG_KEY_RESTRICTCREATION + self::CONFIG_KEY_RESTRICTCREATION, + self::CONFIG_KEY_ALLOWCONFIRMATIONEMAIL, + self::CONFIG_KEY_CONFIRMATIONEMAILRATELIMIT, ]; /** @@ -33,6 +37,8 @@ class Constants { 'formTitle' => 256, 'formDescription' => 8192, 'submissionMessage' => 2048, + 'confirmationEmailSubject' => 255, + 'confirmationEmailBody' => 8192, 'questionText' => 2048, 'questionDescription' => 4096, 'optionText' => 1024, diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index feca1a12f..dbb998e88 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -25,6 +25,7 @@ use OCA\Forms\Exception\NoSuchFormException; use OCA\Forms\ResponseDefinitions; use OCA\Forms\Service\ConfigService; +use OCA\Forms\Service\ConfirmationEmailService; use OCA\Forms\Service\FormsService; use OCA\Forms\Service\SubmissionService; @@ -80,6 +81,7 @@ public function __construct( private QuestionMapper $questionMapper, private ShareMapper $shareMapper, private SubmissionMapper $submissionMapper, + private ConfirmationEmailService $confirmationEmailService, private ConfigService $configService, private FormsService $formsService, private SubmissionService $submissionService, @@ -206,12 +208,15 @@ public function newForm(?int $fromId = null): DataResponse { $formData['showExpiration'] = false; $formData['expires'] = 0; $formData['isAnonymous'] = false; + unset($formData['confirmationEmailQuestionId']); $form = Form::fromParams($formData); $this->formMapper->insert($form); // Get Questions, set new formId, reinsert $questions = $this->questionMapper->findByForm($oldForm->getId()); + $oldConfirmationEmailQuestionId = $oldForm->getConfirmationEmailQuestionId(); + foreach ($questions as $oldQuestion) { $questionData = $oldQuestion->read(); @@ -220,6 +225,10 @@ public function newForm(?int $fromId = null): DataResponse { $newQuestion = Question::fromParams($questionData); $this->questionMapper->insert($newQuestion); + if (isset($oldConfirmationEmailQuestionId) && $oldConfirmationEmailQuestionId === $oldQuestion->getId()) { + $form->setConfirmationEmailQuestionId($newQuestion->getId()); + } + // Get Options, set new QuestionId, reinsert $options = $this->optionMapper->findByQuestion($oldQuestion->getId()); foreach ($options as $oldOption) { @@ -231,6 +240,8 @@ public function newForm(?int $fromId = null): DataResponse { $this->optionMapper->insert($newOption); } } + + $this->formMapper->update($form); } return new DataResponse($this->formsService->getForm($form), Http::STATUS_CREATED); @@ -333,6 +344,22 @@ public function updateForm(int $formId, array $keyValuePairs): DataResponse { unset($keyValuePairs['fileId']); unset($keyValuePairs['fileFormat']); + if (array_key_exists('confirmationEmailQuestionId', $keyValuePairs)) { + try { + $this->confirmationEmailService->validateRecipientQuestionId($form, $keyValuePairs['confirmationEmailQuestionId']); + } catch (\InvalidArgumentException $e) { + throw new OCSBadRequestException('Invalid confirmationEmailQuestionId, will not update.'); + } + } + + foreach (['confirmationEmailSubject', 'confirmationEmailBody'] as $field) { + if (array_key_exists($field, $keyValuePairs) + && is_string($keyValuePairs[$field]) + && mb_strlen($keyValuePairs[$field]) > Constants::MAX_STRING_LENGTHS[$field]) { + throw new OCSBadRequestException($field . ' exceeds maximum length of ' . Constants::MAX_STRING_LENGTHS[$field] . ' characters.'); + } + } + // Create FormEntity with given Params & Id. foreach ($keyValuePairs as $key => $value) { $method = 'set' . ucfirst($key); @@ -649,6 +676,11 @@ public function updateQuestion(int $formId, int $questionId, array $keyValuePair throw new OCSBadRequestException('Invalid extraSettings, will not update.'); } + if ($form->getConfirmationEmailQuestionId() === $question->getId() + && !$question->isEmailType($keyValuePairs['type'] ?? null, $keyValuePairs['extraSettings'] ?? null)) { + $form->setConfirmationEmailQuestionId(null); + } + // Create QuestionEntity with given Params & Id. $question = Question::fromParams($keyValuePairs); $question->setId($questionId); @@ -720,6 +752,10 @@ public function deleteQuestion(int $formId, int $questionId): DataResponse { } } + if ($form->getConfirmationEmailQuestionId() === $questionId) { + $form->setConfirmationEmailQuestionId(null); + } + $this->formMapper->update($form); return new DataResponse($questionId); diff --git a/lib/Db/Form.php b/lib/Db/Form.php index d3c9999df..fe035cfe1 100644 --- a/lib/Db/Form.php +++ b/lib/Db/Form.php @@ -53,6 +53,14 @@ * @method int|null getMaxSubmissions() * @method void setMaxSubmissions(int|null $value) * @method void setLockedUntil(int|null $value) + * @method bool getConfirmationEmailEnabled() + * @method void setConfirmationEmailEnabled(bool $value) + * @method string|null getConfirmationEmailSubject() + * @method void setConfirmationEmailSubject(string|null $value) + * @method string|null getConfirmationEmailBody() + * @method void setConfirmationEmailBody(string|null $value) + * @method int|null getConfirmationEmailQuestionId() + * @method void setConfirmationEmailQuestionId(int|null $value) */ class Form extends Entity { protected $hash; @@ -74,6 +82,10 @@ class Form extends Entity { protected $lockedBy; protected $lockedUntil; protected $maxSubmissions; + protected $confirmationEmailEnabled; + protected $confirmationEmailSubject; + protected $confirmationEmailBody; + protected $confirmationEmailQuestionId; /** * Form constructor. @@ -90,6 +102,8 @@ public function __construct() { $this->addType('lockedBy', 'string'); $this->addType('lockedUntil', 'integer'); $this->addType('maxSubmissions', 'integer'); + $this->addType('confirmationEmailEnabled', 'boolean'); + $this->addType('confirmationEmailQuestionId', 'integer'); } // JSON-Decoding of access-column. @@ -164,6 +178,10 @@ public function setAccess(array $access): void { * lockedBy: ?string, * lockedUntil: ?int, * maxSubmissions: ?int, + * confirmationEmailEnabled: bool, + * confirmationEmailSubject: ?string, + * confirmationEmailBody: ?string, + * confirmationEmailQuestionId: ?int, * } */ public function read() { @@ -188,6 +206,10 @@ public function read() { 'lockedBy' => $this->getLockedBy(), 'lockedUntil' => $this->getLockedUntil(), 'maxSubmissions' => $this->getMaxSubmissions(), + 'confirmationEmailEnabled' => (bool)$this->getConfirmationEmailEnabled(), + 'confirmationEmailSubject' => $this->getConfirmationEmailSubject(), + 'confirmationEmailBody' => $this->getConfirmationEmailBody(), + 'confirmationEmailQuestionId' => $this->getConfirmationEmailQuestionId(), ]; } } diff --git a/lib/Db/Question.php b/lib/Db/Question.php index e5e226cf8..05355b95f 100644 --- a/lib/Db/Question.php +++ b/lib/Db/Question.php @@ -100,4 +100,19 @@ public function read(): array { 'extraSettings' => $this->getExtraSettings(), ]; } + + /** + * Check if the question is a short text question with email validation. + */ + public function isEmailType(?string $type = null, ?array $extraSettings = null): bool { + return self::checkEmailType( + $type ?? $this->getType(), + $extraSettings ?? $this->getExtraSettings() + ); + } + + public static function checkEmailType(string $type, array $extraSettings): bool { + return $type === \OCA\Forms\Constants::ANSWER_TYPE_SHORT + && ($extraSettings['validationType'] ?? null) === 'email'; + } } diff --git a/lib/FormsMigrator.php b/lib/FormsMigrator.php index 5a74db027..e21f25701 100644 --- a/lib/FormsMigrator.php +++ b/lib/FormsMigrator.php @@ -149,6 +149,10 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface $form->setAllowEditSubmissions($formData['allowEditSubmissions']); $form->setShowExpiration($formData['showExpiration']); $form->setMaxSubmissions($formData['maxSubmissions'] ?? null); + $form->setConfirmationEmailEnabled($formData['confirmationEmailEnabled'] ?? false); + $form->setConfirmationEmailSubject($formData['confirmationEmailSubject'] ?? null); + $form->setConfirmationEmailBody($formData['confirmationEmailBody'] ?? null); + $form->setConfirmationEmailQuestionId(null); // Set to null initially, updated after questions are imported $this->formMapper->insert($form); @@ -177,6 +181,12 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface } } + if (($formData['confirmationEmailQuestionId'] ?? null) !== null + && isset($questionIdMap[$formData['confirmationEmailQuestionId']])) { + $form->setConfirmationEmailQuestionId($questionIdMap[$formData['confirmationEmailQuestionId']]); + $this->formMapper->update($form); + } + foreach ($formData['submissions'] as $submissionData) { $submission = new Submission(); $submission->setFormId($form->getId()); diff --git a/lib/Migration/Version050300Date20260413233000.php b/lib/Migration/Version050300Date20260413233000.php new file mode 100644 index 000000000..6e3cd34b5 --- /dev/null +++ b/lib/Migration/Version050300Date20260413233000.php @@ -0,0 +1,70 @@ +getTable('forms_v2_forms'); + $changed = false; + + if (!$table->hasColumn('confirmation_email_enabled')) { + $table->addColumn('confirmation_email_enabled', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => 0, + ]); + $changed = true; + } + + if (!$table->hasColumn('confirmation_email_subject')) { + $table->addColumn('confirmation_email_subject', Types::STRING, [ + 'notnull' => false, + 'default' => null, + 'length' => 255, + ]); + $changed = true; + } + + if (!$table->hasColumn('confirmation_email_body')) { + $table->addColumn('confirmation_email_body', Types::TEXT, [ + 'notnull' => false, + 'default' => null, + ]); + $changed = true; + } + + if (!$table->hasColumn('confirmation_email_question_id')) { + $table->addColumn('confirmation_email_question_id', Types::INTEGER, [ + 'notnull' => false, + 'default' => null, + ]); + $changed = true; + } + + return $changed ? $schema : null; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 27fea25bb..e71b0e51b 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -141,6 +141,10 @@ * shares: list, * submissionCount?: int, * submissionMessage: ?string, + * confirmationEmailEnabled: bool, + * confirmationEmailSubject: ?string, + * confirmationEmailBody: ?string, + * confirmationEmailQuestionId: ?int, * } * * @psalm-type FormsUploadedFile = array{ diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php index 593315c9e..6632bf810 100644 --- a/lib/Service/ConfigService.php +++ b/lib/Service/ConfigService.php @@ -10,6 +10,7 @@ use OCA\Forms\Constants; +use OCP\AppFramework\Services\IAppConfig; use OCP\IConfig; use OCP\IGroup; use OCP\IGroupManager; @@ -22,6 +23,7 @@ class ConfigService { public function __construct( protected string $appName, private IConfig $config, + private IAppConfig $appConfig, private IGroupManager $groupManager, IUserSession $userSession, ) { @@ -50,6 +52,14 @@ public function getRestrictCreation(): bool { return json_decode($this->config->getAppValue($this->appName, Constants::CONFIG_KEY_RESTRICTCREATION, 'false')); } + public function getAllowConfirmationEmail(): bool { + return $this->appConfig->getAppValueBool(Constants::CONFIG_KEY_ALLOWCONFIRMATIONEMAIL, false); + } + + public function getConfirmationEmailRateLimit(): int { + return $this->appConfig->getAppValueInt(Constants::CONFIG_KEY_CONFIRMATIONEMAILRATELIMIT, 3); + } + /** * Provide the full AppConfig */ @@ -60,6 +70,8 @@ public function getAppConfig(): array { Constants::CONFIG_KEY_ALLOWSHOWTOALL => $this->getAllowShowToAll(), Constants::CONFIG_KEY_CREATIONALLOWEDGROUPS => $this->getCreationAllowedGroups(), Constants::CONFIG_KEY_RESTRICTCREATION => $this->getRestrictCreation(), + Constants::CONFIG_KEY_ALLOWCONFIRMATIONEMAIL => $this->getAllowConfirmationEmail(), + Constants::CONFIG_KEY_CONFIRMATIONEMAILRATELIMIT => $this->getConfirmationEmailRateLimit(), // Additional, calculated information out of Config 'canCreateForms' => $this->canCreateForms() diff --git a/lib/Service/ConfirmationEmailService.php b/lib/Service/ConfirmationEmailService.php new file mode 100644 index 000000000..3032a23dd --- /dev/null +++ b/lib/Service/ConfirmationEmailService.php @@ -0,0 +1,263 @@ +rateLimitCache = $cacheFactory->createDistributed('forms_confirmation_email'); + } + + public function send(Form $form, Submission $submission): void { + if (!$form->getConfirmationEmailEnabled()) { + return; + } + + if (!$this->configService->getAllowConfirmationEmail()) { + $this->logger->debug('Confirmation email feature is disabled by administrator', [ + 'formId' => $form->getId(), + ]); + return; + } + + $questions = $this->loadQuestions($form->getId()); + $answerMap = $this->buildAnswerMap($submission); + + $recipientQuestion = $this->findRecipientQuestion($form, $questions); + if ($recipientQuestion === null) { + if ($form->getConfirmationEmailQuestionId() !== null) { + $this->logger->debug('Configured confirmation email recipient question is not a valid email question', [ + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + 'configuredQuestionId' => $form->getConfirmationEmailQuestionId(), + ]); + } + return; + } + + $recipientEmail = $answerMap[$recipientQuestion['id']][0] ?? null; + if ($recipientEmail === null || !$this->emailValidator->isValid($recipientEmail)) { + $this->logger->debug('No valid email address found in submission for confirmation email', [ + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + ]); + return; + } + + if (!$this->checkRateLimit($recipientEmail, $form->getId(), $submission->getId())) { + return; + } + + [$subject, $body] = $this->buildEmailContent($form, $questions, $answerMap); + + $this->jobList->add(SendConfirmationMailJob::class, [ + 'recipient' => $recipientEmail, + 'subject' => $subject, + 'body' => $body, + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + ]); + $this->logger->debug('Confirmation email queued', [ + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + ]); + } + + /** + * @throws \InvalidArgumentException + */ + public function validateRecipientQuestionId(Form $form, mixed $recipientId): void { + if ($recipientId === null) { + return; + } + + if (!is_int($recipientId)) { + throw new \InvalidArgumentException('Invalid confirmationEmailQuestionId'); + } + + try { + $question = $this->questionMapper->findById($recipientId); + } catch (IMapperException $e) { + throw new \InvalidArgumentException('Invalid confirmationEmailQuestionId', previous: $e); + } + + if ($question->getFormId() !== $form->getId() + || $question->getOrder() === 0 + || !$question->isEmailType()) { + throw new \InvalidArgumentException('Invalid confirmationEmailQuestionId'); + } + } + + /** + * Load questions for a form. Skips options and file-type processing not needed for email. + * + * @return list> + */ + private function loadQuestions(int $formId): array { + $questions = []; + try { + foreach ($this->questionMapper->findByForm($formId) as $entity) { + $questions[] = $entity->read(); + } + } catch (\Exception $e) { + $this->logger->debug('Failed to load questions for confirmation email placeholder substitution', [ + 'formId' => $formId, + 'exception' => $e, + ]); + } + return $questions; + } + + /** + * @return array + */ + private function buildAnswerMap(Submission $submission): array { + $map = []; + foreach ($this->answerMapper->findBySubmission($submission->getId()) as $answer) { + $map[$answer->getQuestionId()][] = $answer->getText(); + } + return $map; + } + + /** + * @param list> $questions + * @param array $answerMap + * @return array{string, string} + */ + private function buildEmailContent(Form $form, array $questions, array $answerMap): array { + $subject = $form->getConfirmationEmailSubject(); + $body = $form->getConfirmationEmailBody(); + + if (empty($subject)) { + $subject = $this->l10n->t('Thank you for your submission'); + } + if (empty($body)) { + $body = $this->l10n->t('Thank you for submitting the form "%s".', [$form->getTitle()]); + } + + $replacements = [ + '{formTitle}' => $form->getTitle(), + '{formDescription}' => $form->getDescription() ?? '', + ]; + + foreach ($questions as $question) { + $fieldKey = !empty($question['name'] ?? '') ? $question['name'] : ($question['text'] ?? ''); + $fieldKey = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $fieldKey)); + if ($fieldKey === '' || empty($answerMap[$question['id']])) { + continue; + } + $placeholder = '{' . $fieldKey . '}'; + if (isset($replacements[$placeholder])) { + $this->logger->warning('Confirmation email placeholder key collision, skipping duplicate', [ + 'formId' => $form->getId(), + 'key' => $fieldKey, + ]); + continue; + } + $replacements[$placeholder] = implode('; ', $answerMap[$question['id']]); + } + + return [ + str_replace(array_keys($replacements), array_values($replacements), $subject), + str_replace(array_keys($replacements), array_values($replacements), $body), + ]; + } + + /** + * @param list> $questions + * @return array|null + */ + private function findRecipientQuestion(Form $form, array $questions): ?array { + $recipientQuestionId = $form->getConfirmationEmailQuestionId(); + if ($recipientQuestionId === null) { + return null; + } + + foreach ($questions as $questionData) { + if (($questionData['id'] ?? null) !== $recipientQuestionId) { + continue; + } + + if (Question::checkEmailType( + $questionData['type'] ?? '', + (array)($questionData['extraSettings'] ?? []) + )) { + return $questionData; + } + + return null; + } + + return null; + } + + private function checkRateLimit(string $email, int $formId, int $submissionId): bool { + $cacheKey = 'email_rl_' . hash('sha256', $formId . ':' . strtolower($email)); + + if (!$this->rateLimitCache instanceof IMemcache) { + // Atomic increment requires IMemcache; without it we cannot safely count. + $this->logger->debug('Distributed cache unavailable, skipping confirmation email rate limit', [ + 'formId' => $formId, + ]); + return true; + } + + $rateLimit = $this->configService->getConfirmationEmailRateLimit(); + + if ($this->rateLimitCache->add($cacheKey, 1, self::RATE_LIMIT_TTL)) { + $count = 1; + } else { + $count = $this->rateLimitCache->inc($cacheKey); + if (!is_int($count)) { + $this->logger->warning('Failed to increment confirmation email rate limit counter', [ + 'formId' => $formId, + 'submissionId' => $submissionId, + ]); + return false; + } + } + + if ($count > $rateLimit) { + $this->logger->warning('Per-recipient confirmation email rate limit reached', [ + 'formId' => $formId, + 'submissionId' => $submissionId, + ]); + return false; + } + + return true; + } +} diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index f5b421c3a..d0e93f5b8 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -67,6 +67,7 @@ public function __construct( private IL10N $l10n, private LoggerInterface $logger, private IEventDispatcher $eventDispatcher, + private ConfirmationEmailService $confirmationEmailService, ) { $this->currentUser = $userSession->getUser(); } @@ -739,6 +740,8 @@ public function notifyNewSubmission(Form $form, Submission $submission): void { } $this->eventDispatcher->dispatchTyped(new FormSubmittedEvent($form, $submission)); + + $this->confirmationEmailService->send($form, $submission); } /** @@ -1015,4 +1018,5 @@ public function getTemporaryUploadedFilePath(Form $form, Question $question): st private static function normalizeFileName(string $fileName): string { return trim(str_replace(Constants::FILENAME_INVALID_CHARS, '-', $fileName)); } + } diff --git a/openapi.json b/openapi.json index 9d80a1151..a423c4546 100644 --- a/openapi.json +++ b/openapi.json @@ -119,7 +119,11 @@ "lockedUntil", "maxSubmissions", "shares", - "submissionMessage" + "submissionMessage", + "confirmationEmailEnabled", + "confirmationEmailSubject", + "confirmationEmailBody", + "confirmationEmailQuestionId" ], "properties": { "id": { @@ -232,6 +236,22 @@ "submissionMessage": { "type": "string", "nullable": true + }, + "confirmationEmailEnabled": { + "type": "boolean" + }, + "confirmationEmailSubject": { + "type": "string", + "nullable": true + }, + "confirmationEmailBody": { + "type": "string", + "nullable": true + }, + "confirmationEmailQuestionId": { + "type": "integer", + "format": "int64", + "nullable": true } } }, diff --git a/src/FormsSettings.vue b/src/FormsSettings.vue index 60c607c8e..46735d411 100644 --- a/src/FormsSettings.vue +++ b/src/FormsSettings.vue @@ -24,6 +24,37 @@ label="displayName" @input="onCreationAllowedGroupsChange" /> + + + {{ t('forms', 'Allow confirmation emails to form respondents') }} + + + diff --git a/src/components/SidebarTabs/SettingsSidebarTab.vue b/src/components/SidebarTabs/SettingsSidebarTab.vue index b973b6e54..723a37ee2 100644 --- a/src/components/SidebarTabs/SettingsSidebarTab.vue +++ b/src/components/SidebarTabs/SettingsSidebarTab.vue @@ -194,6 +194,70 @@ + + + question.type === 'short' + && question.extraSettings?.validationType === 'email', + ) + }, + + selectedConfirmationEmailQuestion() { + const selectedQuestion = this.confirmationEmailQuestions.find( + (question) => question.id === this.form.confirmationEmailQuestionId, + ) + if (selectedQuestion) { + return selectedQuestion + } + + if ( + this.form.confirmationEmailQuestionId === null + && this.emailQuestionCount === 1 + ) { + return this.confirmationEmailQuestions[0] + } + + return null + }, + + selectedConfirmationEmailQuestionId() { + return ( + this.form.confirmationEmailQuestionId + ?? this.selectedConfirmationEmailQuestion?.id + ?? '' + ) + }, + + confirmationEmailQuestionOptions() { + return this.confirmationEmailQuestions.map((question) => ({ + id: question.id, + label: this.confirmationEmailQuestionLabel(question), + })) + }, + + selectedConfirmationEmailQuestionOption() { + return ( + this.confirmationEmailQuestionOptions.find( + (question) => + question.id === this.selectedConfirmationEmailQuestionId, + ) || null + ) + }, + + confirmationEmailErrorText() { + if (this.emailQuestionCount === 0) { + return t( + 'forms', + 'Add at least one email field before confirmation emails can be used.', + ) + } + + if (this.requiresConfirmationEmailQuestionIdSelection) { + return t( + 'forms', + 'Select which email field should receive confirmation emails before finishing this setup.', + ) + } + + return '' + }, + + confirmationEmailNoteCardType() { + if (this.requiresConfirmationEmailQuestionIdSelection) { + return 'warning' + } + return 'info' + }, + + requiresConfirmationEmailQuestionIdSelection() { + return ( + this.emailQuestionCount > 1 + && !this.selectedConfirmationEmailQuestion + ) + }, + + isConfirmationEmailConfigurationBlocked() { + return ( + this.form.confirmationEmailEnabled + && (this.emailQuestionCount === 0 + || this.requiresConfirmationEmailQuestionIdSelection) + ) + }, + }, + + watch: { + 'form.confirmationEmailSubject': function (val) { + this.confirmationEmailSubject = val || '' + }, + + 'form.confirmationEmailBody': function (val) { + this.confirmationEmailBody = val || '' + }, + + confirmationEmailQuestions: { + handler() { + const selectedRecipientId = this.form.confirmationEmailQuestionId + const hasValidSelectedRecipient = + selectedRecipientId !== null + && this.confirmationEmailQuestions.some( + (question) => question.id === selectedRecipientId, + ) + + if (selectedRecipientId !== null && !hasValidSelectedRecipient) { + if (this.emailQuestionCount === 1) { + this.saveConfirmationEmailQuestionId( + this.confirmationEmailQuestions[0].id, + ) + } else { + this.saveConfirmationEmailQuestionId(null) + } + return + } + + if ( + this.form.confirmationEmailEnabled + && this.emailQuestionCount === 1 + && this.form.confirmationEmailQuestionId === null + ) { + this.saveConfirmationEmailQuestionId( + this.confirmationEmailQuestions[0].id, + ) + } + }, + + deep: true, + }, }, methods: { + confirmationEmailQuestionLabel(question) { + return question.text || t('forms', 'Untitled question') + }, + /** * Save Form-Properties * @@ -456,6 +677,57 @@ export default { } }, + onConfirmationEmailEnabledChange(checked) { + if ( + checked + && this.form.confirmationEmailQuestionId === null + && this.emailQuestionCount === 1 + ) { + this.saveConfirmationEmailQuestionId( + this.confirmationEmailQuestions[0].id, + ) + } + + this.$emit('update:formProp', 'confirmationEmailEnabled', checked) + }, + + onConfirmationEmailSubjectChange() { + this.$emit( + 'update:formProp', + 'confirmationEmailSubject', + this.confirmationEmailSubject, + ) + }, + + onConfirmationEmailBodyChange() { + this.$emit( + 'update:formProp', + 'confirmationEmailBody', + this.confirmationEmailBody, + ) + }, + + onConfirmationEmailQuestionIdSelectionChange(option) { + const questionId = option?.id ?? null + if (questionId === null) { + return + } + + this.saveConfirmationEmailQuestionId(questionId) + }, + + saveConfirmationEmailQuestionId(selectedQuestionId) { + if (this.form.confirmationEmailQuestionId === selectedQuestionId) { + return + } + + this.$emit( + 'update:formProp', + 'confirmationEmailQuestionId', + selectedQuestionId, + ) + }, + /** * Datepicker timestamp to string * @@ -550,4 +822,42 @@ export default { } } } + +.confirmation-email { + &__recipient { + margin-bottom: calc(var(--default-grid-baseline) * 3); + } + + &__label { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-bottom: var(--default-grid-baseline); + font-weight: 600; + color: var(--color-text-maxcontrast); + } + + &__placeholder-hint { + color: var(--color-text-maxcontrast); + font-size: var(--font-size-small); + margin-top: calc(var(--default-grid-baseline) * 2); + } + + &__select { + width: 100%; + + // NcSelect sets min-width: 260px with two-class specificity; double + // our class to win the cascade without !important. + &#{&} { + min-width: 0; + } + } + + &__input, + &__textarea { + width: 100%; + margin-top: calc(var(--default-grid-baseline) * 3); + } +} diff --git a/tests/Integration/Api/ApiV3Test.php b/tests/Integration/Api/ApiV3Test.php index 0605ffe91..e7378d287 100644 --- a/tests/Integration/Api/ApiV3Test.php +++ b/tests/Integration/Api/ApiV3Test.php @@ -397,6 +397,10 @@ public static function dataGetNewForm() { 'fileFormat' => null, 'maxSubmissions' => null, 'isMaxSubmissionsReached' => false, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, + 'confirmationEmailQuestionId' => null, ] ] ]; @@ -530,6 +534,10 @@ public static function dataGetFullForm() { 'fileFormat' => null, 'maxSubmissions' => null, 'isMaxSubmissionsReached' => false, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, + 'confirmationEmailQuestionId' => null, ] ] ]; diff --git a/tests/Integration/Api/RespectAdminSettingsTest.php b/tests/Integration/Api/RespectAdminSettingsTest.php index 09d8ff7d1..29e088985 100644 --- a/tests/Integration/Api/RespectAdminSettingsTest.php +++ b/tests/Integration/Api/RespectAdminSettingsTest.php @@ -145,6 +145,10 @@ private static function sharedTestForms(): array { 'submissionCount' => 0, 'maxSubmissions' => null, 'isMaxSubmissionsReached' => false, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, + 'confirmationEmailQuestionId' => null, ], ]; } diff --git a/tests/Unit/Controller/ApiControllerTest.php b/tests/Unit/Controller/ApiControllerTest.php index 28c77c60b..89ef8478d 100644 --- a/tests/Unit/Controller/ApiControllerTest.php +++ b/tests/Unit/Controller/ApiControllerTest.php @@ -45,6 +45,7 @@ function is_uploaded_file(string|bool|null $filename) { use OCA\Forms\Db\UploadedFileMapper; use OCA\Forms\Exception\NoSuchFormException; use OCA\Forms\Service\ConfigService; +use OCA\Forms\Service\ConfirmationEmailService; use OCA\Forms\Service\FormsService; use OCA\Forms\Service\SubmissionService; use OCP\AppFramework\Db\DoesNotExistException; @@ -85,6 +86,8 @@ class ApiControllerTest extends TestCase { private $shareMapper; /** @var SubmissionMapper|MockObject */ private $submissionMapper; + /** @var ConfirmationEmailService|MockObject */ + private $confirmationEmailService; /** @var ConfigService|MockObject */ private $configService; /** @var FormsService|MockObject */ @@ -115,6 +118,7 @@ public function setUp(): void { $this->questionMapper = $this->createMock(QuestionMapper::class); $this->shareMapper = $this->createMock(ShareMapper::class); $this->submissionMapper = $this->createMock(SubmissionMapper::class); + $this->confirmationEmailService = $this->createMock(ConfirmationEmailService::class); $this->configService = $this->createMock(ConfigService::class); $this->formsService = $this->createMock(FormsService::class); $this->submissionService = $this->createMock(SubmissionService::class); @@ -142,6 +146,7 @@ public function setUp(): void { $this->questionMapper, $this->shareMapper, $this->submissionMapper, + $this->confirmationEmailService, $this->configService, $this->formsService, $this->submissionService, @@ -516,6 +521,10 @@ public static function dataTestCreateNewForm() { 'lockedBy' => null, 'lockedUntil' => null, 'maxSubmissions' => null, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, + 'confirmationEmailQuestionId' => null, ]] ]; } @@ -534,7 +543,11 @@ public function testCreateNewForm($expectedForm) { $expected['id'] = null; // TODO fix test, currently unset because behaviour has changed $expected['state'] = null; - $expected['lastUpdated'] = null; + $expected['lastUpdated'] = 0; + $expected['confirmationEmailEnabled'] = false; + $expected['confirmationEmailSubject'] = null; + $expected['confirmationEmailBody'] = null; + $expected['confirmationEmailQuestionId'] = null; $this->formMapper->expects($this->once()) ->method('insert') ->with(self::callback(self::createFormValidator($expected))) @@ -1149,6 +1162,190 @@ public function testDeleteAllSubmissionsWithResultsDeletePermission(): void { $this->assertEquals(new DataResponse(1), $this->apiController->deleteAllSubmissions(1)); } + public function testUpdateFormAllowsValidConfirmationEmailQuestionId(): void { + $form = new Form(); + $form->setId(1); + $form->setOwnerId('currentUser'); + + $this->formsService + ->method('getFormIfAllowed') + ->with(1, Constants::PERMISSION_EDIT) + ->willReturn($form); + + $this->confirmationEmailService->expects($this->once()) + ->method('validateRecipientQuestionId') + ->with($form, 7); + + $this->formMapper->expects($this->once()) + ->method('update') + ->with($form); + + $this->assertEquals(new DataResponse(1), $this->apiController->updateForm(1, ['confirmationEmailQuestionId' => 7])); + $this->assertSame(7, $form->getConfirmationEmailQuestionId()); + } + + public static function dataUpdateFormRejectsInvalidConfirmationEmailQuestionId(): array { + return [ + 'invalid-question-id' => ['Invalid question ID'], + 'question-from-another-form' => ['Question belongs to a different form'], + 'deleted-question' => ['Question has been deleted'], + ]; + } + + /** + * @dataProvider dataUpdateFormRejectsInvalidConfirmationEmailQuestionId + */ + public function testUpdateFormRejectsInvalidConfirmationEmailQuestionId(string $reason): void { + $form = new Form(); + $form->setId(1); + $form->setOwnerId('currentUser'); + + $this->formsService + ->method('getFormIfAllowed') + ->with(1, Constants::PERMISSION_EDIT) + ->willReturn($form); + + $this->confirmationEmailService->expects($this->once()) + ->method('validateRecipientQuestionId') + ->with($form, 7) + ->willThrowException(new \InvalidArgumentException($reason)); + + $this->formMapper->expects($this->never())->method('update'); + + $this->expectException(OCSBadRequestException::class); + $this->apiController->updateForm(1, ['confirmationEmailQuestionId' => 7]); + } + + public function testUpdateQuestionClearsConfirmationEmailQuestionIdWhenQuestionStopsBeingEmail(): void { + $form = new Form(); + $form->setId(1); + $form->setOwnerId('currentUser'); + $form->setConfirmationEmailQuestionId(7); + + $question = new Question(); + $question->setId(7); + $question->setFormId(1); + $question->setType(Constants::ANSWER_TYPE_SHORT); + $question->setExtraSettings(['validationType' => 'email']); + + $this->formsService + ->method('getFormIfAllowed') + ->with(1, Constants::PERMISSION_EDIT) + ->willReturn($form); + $this->formsService + ->method('isFormArchived') + ->with($form) + ->willReturn(false); + $this->formsService + ->method('areExtraSettingsValid') + ->with(['validationType' => 'text'], Constants::ANSWER_TYPE_SHORT) + ->willReturn(true); + + $this->questionMapper->expects($this->once()) + ->method('findById') + ->with(7) + ->willReturn($question); + $this->questionMapper->expects($this->once()) + ->method('update') + ->with($this->callback(function (Question $updatedQuestion): bool { + return $updatedQuestion->getId() === 7; + })); + + $this->formMapper->expects($this->once()) + ->method('update') + ->with($form); + + $this->assertEquals(new DataResponse(7), $this->apiController->updateQuestion(1, 7, [ + 'extraSettings' => ['validationType' => 'text'], + ])); + $this->assertNull($form->getConfirmationEmailQuestionId()); + } + + public function testDeleteQuestionClearsConfirmationEmailQuestionIdWhenDeletingSelectedQuestion(): void { + $form = new Form(); + $form->setId(1); + $form->setOwnerId('currentUser'); + $form->setConfirmationEmailQuestionId(7); + + $question = new Question(); + $question->setId(7); + $question->setFormId(1); + $question->setOrder(1); + + $this->formsService + ->method('getFormIfAllowed') + ->with(1, Constants::PERMISSION_EDIT) + ->willReturn($form); + $this->formsService + ->method('isFormArchived') + ->with($form) + ->willReturn(false); + + $this->questionMapper->expects($this->once()) + ->method('findById') + ->with(7) + ->willReturn($question); + $this->questionMapper->expects($this->once()) + ->method('findByForm') + ->with(1) + ->willReturn([]); + $this->questionMapper->expects($this->once()) + ->method('update') + ->with($this->callback(function (Question $updatedQuestion): bool { + return $updatedQuestion->getId() === 7 && $updatedQuestion->getOrder() === 0; + })); + + $this->formMapper->expects($this->once()) + ->method('update') + ->with($form); + + $this->assertEquals(new DataResponse(7), $this->apiController->deleteQuestion(1, 7)); + $this->assertNull($form->getConfirmationEmailQuestionId()); + } + + public function testCloneFormWithConfirmationEmailQuestionId(): void { + $this->configService->method('canCreateForms')->willReturn(true); + + $oldForm = Form::fromParams([ + 'id' => 7, + 'title' => 'Old Form', + 'ownerId' => 'currentUser', + 'confirmationEmailQuestionId' => 10, + ]); + $this->formsService->method('getFormIfAllowed')->with(7)->willReturn($oldForm); + $this->formsService->method('generateFormHash')->willReturn('new hash'); + + $question = new Question(); + $question->setId(10); + $question->setFormId(7); + $this->questionMapper->method('findByForm')->with(7)->willReturn([$question]); + + $clonedForm = null; + $this->formMapper->expects($this->once()) + ->method('insert') + ->with($this->callback(function (Form $form) use (&$clonedForm) { + $form->setId(14); + $clonedForm = $form; + return true; + })); + + $this->questionMapper->expects($this->once()) + ->method('insert') + ->with($this->callback(function (Question $newQuestion) { + $newQuestion->setId(11); + return true; + })); + + $this->formMapper->expects($this->once()) + ->method('update') + ->with($this->callback(function (Form $form) { + return $form->getId() === 14 && $form->getConfirmationEmailQuestionId() === 11; + })); + + $this->apiController->newForm(7); + $this->assertEquals(11, $clonedForm->getConfirmationEmailQuestionId()); + } + public function testTransferOwnerNotOwner() { $form = new Form(); $form->setId(1); diff --git a/tests/Unit/Db/QuestionTest.php b/tests/Unit/Db/QuestionTest.php new file mode 100644 index 000000000..ebc58f72d --- /dev/null +++ b/tests/Unit/Db/QuestionTest.php @@ -0,0 +1,63 @@ +assertSame($expected, Question::checkEmailType($type, $extraSettings)); + } + + public static function isEmailTypeData(): array { + return [ + 'valid-email' => [ + Constants::ANSWER_TYPE_SHORT, + ['validationType' => 'email'], + true + ], + 'invalid-type-long' => [ + Constants::ANSWER_TYPE_LONG, + ['validationType' => 'email'], + false + ], + 'invalid-validation-type-text' => [ + Constants::ANSWER_TYPE_SHORT, + ['validationType' => 'text'], + false + ], + 'invalid-validation-type-none' => [ + Constants::ANSWER_TYPE_SHORT, + [], + false + ], + 'invalid-type-multiple' => [ + Constants::ANSWER_TYPE_MULTIPLE, + ['validationType' => 'email'], + false + ], + ]; + } + + public function testIsEmailTypeEntity(): void { + $question = new Question(); + $question->setType(Constants::ANSWER_TYPE_SHORT); + $question->setExtraSettings(['validationType' => 'email']); + + $this->assertTrue($question->isEmailType()); + + $question->setExtraSettings(['validationType' => 'text']); + $this->assertFalse($question->isEmailType()); + } +} diff --git a/tests/Unit/FormsMigratorTest.php b/tests/Unit/FormsMigratorTest.php index 97f584a6c..6113ce8a2 100644 --- a/tests/Unit/FormsMigratorTest.php +++ b/tests/Unit/FormsMigratorTest.php @@ -111,8 +111,12 @@ public static function dataExport() { "allowEditSubmissions": false, "showExpiration": false, "lastUpdated": 123456789, - "submissionMessage": "Back to website", - "questions": [ + "submissionMessage": "Back to website", + "confirmationEmailEnabled": false, + "confirmationEmailSubject": "Thanks", + "confirmationEmailBody": "Body", + "confirmationEmailQuestionId": null, + "questions": [ { "id": 14, "order": 2, @@ -186,6 +190,10 @@ public function testExport(string $expectedJson) { $form->setShowExpiration(false); $form->setLastUpdated(123456789); $form->setSubmissionMessage('Back to website'); + $form->setConfirmationEmailEnabled(false); + $form->setConfirmationEmailSubject('Thanks'); + $form->setConfirmationEmailBody('Body'); + $form->setConfirmationEmailQuestionId(null); $this->formsService->expects($this->once()) ->method('getQuestions') @@ -255,7 +263,7 @@ public function testExport(string $expectedJson) { public static function dataImport() { return [ 'exactlyOneOfEach' => [ - 'inputJson' => '[{"title":"Link","description":"","created":1646251830,"access":{"permitAllUsers":false,"showToAllUsers":false},"expires":0,"state":0,"lockedBy":null,"lockedUntil":null,"maxSubmissions":null,"isAnonymous":false,"submitMultiple":false,"allowEditSubmissions":false,"showExpiration":false,"lastUpdated":123456789,"questions":[{"id":14,"order":2,"type":"multiple","isRequired":false,"text":"checkbox","description":"huhu","extraSettings":{},"options":[{"text":"ans1"}]}],"submissions":[{"userId":"anyUser@localhost","timestamp":1651354059,"answers":[{"questionId":14,"text":"ans1"}]}]}]' + 'inputJson' => '[{"title":"Link","description":"","created":1646251830,"access":{"permitAllUsers":false,"showToAllUsers":false},"expires":0,"state":0,"lockedBy":null,"lockedUntil":null,"maxSubmissions":null,"isAnonymous":false,"submitMultiple":false,"allowEditSubmissions":false,"showExpiration":false,"lastUpdated":123456789,"confirmationEmailEnabled":true,"confirmationEmailSubject":"Thanks","confirmationEmailBody":"Body","confirmationEmailQuestionId":null,"questions":[{"id":14,"order":2,"type":"multiple","isRequired":false,"text":"checkbox","description":"huhu","extraSettings":{},"options":[{"text":"ans1"}]}],"submissions":[{"userId":"anyUser@localhost","timestamp":1651354059,"answers":[{"questionId":14,"text":"ans1"}]}]}]' ] ]; } @@ -285,7 +293,14 @@ public function testImport(string $inputJson) { ->method('generateFormHash') ->willReturn('abcdefg'); - $this->formMapper->expects($this->once())->method('insert'); + $this->formMapper->expects($this->once()) + ->method('insert') + ->willReturnCallback(function (Form $form) { + $this->assertTrue($form->getConfirmationEmailEnabled()); + $this->assertSame('Thanks', $form->getConfirmationEmailSubject()); + $this->assertSame('Body', $form->getConfirmationEmailBody()); + return $form; + }); $this->questionMapper->expects($this->once())->method('insert'); $this->optionMapper->expects($this->once())->method('insert'); $this->submissionMapper->expects($this->once())->method('insert'); diff --git a/tests/Unit/Service/ConfigServiceTest.php b/tests/Unit/Service/ConfigServiceTest.php index d0ff7a420..30fdfd169 100644 --- a/tests/Unit/Service/ConfigServiceTest.php +++ b/tests/Unit/Service/ConfigServiceTest.php @@ -9,6 +9,7 @@ use OCA\Forms\Service\ConfigService; +use OCP\AppFramework\Services\IAppConfig; use OCP\IConfig; use OCP\IGroup; use OCP\IGroupManager; @@ -27,6 +28,9 @@ class ConfigServiceTest extends TestCase { /** @var IConfig|MockObject */ private $config; + /** @var IAppConfig|MockObject */ + private $appConfig; + /** @var IGroupManager|MockObject */ private $groupManager; @@ -37,6 +41,7 @@ public function setUp(): void { parent::setUp(); $this->config = $this->createMock(IConfig::class); + $this->appConfig = $this->createMock(IAppConfig::class); $this->groupManager = $this->createMock(IGroupManager::class); $userSession = $this->createMock(IUserSession::class); @@ -51,6 +56,7 @@ public function setUp(): void { $this->configService = new ConfigService( 'forms', $this->config, + $this->appConfig, $this->groupManager, $userSession ); @@ -65,6 +71,8 @@ public static function dataGetAppConfig() { 'allowShowToAll' => 'false', 'creationAllowedGroups' => '["group1", "group2", "nonExisting"]', 'restrictCreation' => 'true', + 'allowConfirmationEmail' => 'false', + 'confirmationEmailRateLimit' => '5', ], 'groupDisplayNames' => [ 'group1' => 'Group No. 1', @@ -85,7 +93,8 @@ public static function dataGetAppConfig() { ] ], 'restrictCreation' => true, - + 'allowConfirmationEmail' => false, + 'confirmationEmailRateLimit' => 5, 'canCreateForms' => false ] ] @@ -103,7 +112,7 @@ public function testGetAppConfig(array $strConfig, array $groupDisplayNames, arr $this->config->expects($this->any()) ->method('getAppValue') ->will($this->returnCallback(function ($appName, $configKey, $defaultVal) use ($strConfig) { - return $strConfig[$configKey]; + return $strConfig[$configKey] ?? $defaultVal; })); @@ -127,6 +136,13 @@ public function testGetAppConfig(array $strConfig, array $groupDisplayNames, arr ->with($this->currentUser) ->willReturn([]); + $this->appConfig->expects($this->any()) + ->method('getAppValueBool') + ->willReturn($expected['allowConfirmationEmail']); + $this->appConfig->expects($this->any()) + ->method('getAppValueInt') + ->willReturn($expected['confirmationEmailRateLimit']); + $this->assertEquals($expected, $this->configService->getAppConfig()); } @@ -139,6 +155,8 @@ public static function dataGetAppConfig_Default() { 'allowShowToAll' => true, 'creationAllowedGroups' => [], 'restrictCreation' => false, + 'allowConfirmationEmail' => false, + 'confirmationEmailRateLimit' => 3, 'canCreateForms' => true ] ] @@ -157,6 +175,13 @@ public function testGetAppConfig_Default(array $expected) { return $defaultVal; })); + $this->appConfig->expects($this->any()) + ->method('getAppValueBool') + ->willReturn($expected['allowConfirmationEmail']); + $this->appConfig->expects($this->any()) + ->method('getAppValueInt') + ->willReturn($expected['confirmationEmailRateLimit']); + $this->assertEquals($expected, $this->configService->getAppConfig()); } diff --git a/tests/Unit/Service/ConfirmationEmailServiceTest.php b/tests/Unit/Service/ConfirmationEmailServiceTest.php new file mode 100644 index 000000000..afc819872 --- /dev/null +++ b/tests/Unit/Service/ConfirmationEmailServiceTest.php @@ -0,0 +1,465 @@ +configService = $this->createMock(ConfigService::class); + $this->answerMapper = $this->createMock(AnswerMapper::class); + $this->questionMapper = $this->createMock(QuestionMapper::class); + $this->emailValidator = $this->createMock(IEmailValidator::class); + $this->jobList = $this->createMock(IJobList::class); + $this->cacheFactory = $this->createMock(ICacheFactory::class); + $this->cache = $this->createMock(IMemcache::class); + $this->l10n = $this->createMock(IL10N::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->l10n->method('t')->willReturnCallback( + fn (string $text, array $params = []) => $params ? vsprintf($text, $params) : $text + ); + + $this->cacheFactory->method('createDistributed') + ->with('forms_confirmation_email') + ->willReturn($this->cache); + + $this->service = new ConfirmationEmailService( + $this->configService, + $this->answerMapper, + $this->questionMapper, + $this->emailValidator, + $this->jobList, + $this->cacheFactory, + $this->l10n, + $this->logger, + ); + } + + private function createQuestionEntity(array $data): MockObject { + $entity = $this->createMock(Question::class); + $entity->method('read')->willReturn($data); + return $entity; + } + + private function makeEmailAnswer(int $submissionId, int $questionId, string $email): Answer { + $answer = new Answer(); + $answer->setSubmissionId($submissionId); + $answer->setQuestionId($questionId); + $answer->setText($email); + return $answer; + } + + public function testSendDoesNothingIfConfirmationEmailDisabled(): void { + $form = Form::fromParams(['id' => 1, 'confirmationEmailEnabled' => false]); + $submission = new Submission(); + $submission->setId(10); + + $this->jobList->expects($this->never())->method('add'); + + $this->service->send($form, $submission); + } + + public function testSendDoesNothingIfAdminDisabled(): void { + $form = Form::fromParams(['id' => 1, 'confirmationEmailEnabled' => true]); + $submission = new Submission(); + $submission->setId(10); + + $this->configService->method('getAllowConfirmationEmail')->willReturn(false); + $this->jobList->expects($this->never())->method('add'); + + $this->service->send($form, $submission); + } + + public function testSendDoesNothingIfNoRecipientQuestion(): void { + $form = Form::fromParams([ + 'id' => 1, + 'confirmationEmailEnabled' => true, + 'confirmationEmailQuestionId' => null, + ]); + $submission = new Submission(); + $submission->setId(10); + + $this->configService->method('getAllowConfirmationEmail')->willReturn(true); + $this->questionMapper->method('findByForm')->willReturn([]); + $this->jobList->expects($this->never())->method('add'); + + $this->service->send($form, $submission); + } + + public function testSendDoesNothingIfRecipientQuestionChangedType(): void { + $form = Form::fromParams([ + 'id' => 1, + 'confirmationEmailEnabled' => true, + 'confirmationEmailQuestionId' => 5, + ]); + $submission = new Submission(); + $submission->setId(10); + + $this->configService->method('getAllowConfirmationEmail')->willReturn(true); + $this->questionMapper->method('findByForm')->willReturn([ + $this->createQuestionEntity(['id' => 5, 'type' => Constants::ANSWER_TYPE_LONG]), + ]); + $this->jobList->expects($this->never())->method('add'); + + $this->service->send($form, $submission); + } + + public function testSendDoesNothingIfNoEmailAnswer(): void { + $form = Form::fromParams([ + 'id' => 1, + 'confirmationEmailEnabled' => true, + 'confirmationEmailQuestionId' => 5, + ]); + $submission = new Submission(); + $submission->setId(10); + + $this->configService->method('getAllowConfirmationEmail')->willReturn(true); + $this->questionMapper->method('findByForm')->willReturn([ + $this->createQuestionEntity([ + 'id' => 5, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'extraSettings' => ['validationType' => 'email'], + ]), + ]); + $this->answerMapper->method('findBySubmission')->willReturn([]); + $this->jobList->expects($this->never())->method('add'); + + $this->service->send($form, $submission); + } + + public function testSendDoesNothingIfEmailAnswerIsInvalid(): void { + $form = Form::fromParams([ + 'id' => 1, + 'confirmationEmailEnabled' => true, + 'confirmationEmailQuestionId' => 5, + ]); + $submission = new Submission(); + $submission->setId(10); + + $this->configService->method('getAllowConfirmationEmail')->willReturn(true); + $this->questionMapper->method('findByForm')->willReturn([ + $this->createQuestionEntity([ + 'id' => 5, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'extraSettings' => ['validationType' => 'email'], + ]), + ]); + $this->answerMapper->method('findBySubmission')->willReturn([ + $this->makeEmailAnswer(10, 5, 'not-an-email'), + ]); + $this->emailValidator->method('isValid')->with('not-an-email')->willReturn(false); + $this->jobList->expects($this->never())->method('add'); + + $this->service->send($form, $submission); + } + + public function testSendQueuesJobWithDefaultSubjectAndBody(): void { + $form = Form::fromParams([ + 'id' => 1, + 'title' => 'My Form', + 'confirmationEmailEnabled' => true, + 'confirmationEmailQuestionId' => 5, + ]); + $submission = new Submission(); + $submission->setId(10); + + $this->configService->method('getAllowConfirmationEmail')->willReturn(true); + $this->configService->method('getConfirmationEmailRateLimit')->willReturn(3); + $this->questionMapper->method('findByForm')->willReturn([ + $this->createQuestionEntity([ + 'id' => 5, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'extraSettings' => ['validationType' => 'email'], + ]), + ]); + $this->answerMapper->method('findBySubmission')->willReturn([ + $this->makeEmailAnswer(10, 5, 'user@example.com'), + ]); + $this->emailValidator->method('isValid')->with('user@example.com')->willReturn(true); + + $this->cache->method('add')->willReturn(true); + + $this->jobList->expects($this->once()) + ->method('add') + ->with( + SendConfirmationMailJob::class, + $this->callback(function (array $payload): bool { + $this->assertSame('user@example.com', $payload['recipient']); + $this->assertSame('Thank you for your submission', $payload['subject']); + $this->assertStringContainsString('My Form', $payload['body']); + $this->assertSame(1, $payload['formId']); + $this->assertSame(10, $payload['submissionId']); + return true; + }) + ); + + $this->service->send($form, $submission); + } + + public function testSendQueuesJobWithCustomSubjectAndBodyAndPlaceholders(): void { + $form = Form::fromParams([ + 'id' => 1, + 'title' => 'Survey', + 'confirmationEmailEnabled' => true, + 'confirmationEmailQuestionId' => 5, + 'confirmationEmailSubject' => 'Hi {name}', + 'confirmationEmailBody' => 'Your answer: {email}', + ]); + $submission = new Submission(); + $submission->setId(10); + + $this->configService->method('getAllowConfirmationEmail')->willReturn(true); + $this->configService->method('getConfirmationEmailRateLimit')->willReturn(3); + $this->questionMapper->method('findByForm')->willReturn([ + $this->createQuestionEntity([ + 'id' => 3, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'text' => 'Name', + 'name' => 'name', + ]), + $this->createQuestionEntity([ + 'id' => 5, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'text' => 'Email', + 'name' => 'email', + 'extraSettings' => ['validationType' => 'email'], + ]), + ]); + + $nameAnswer = new Answer(); + $nameAnswer->setSubmissionId(10); + $nameAnswer->setQuestionId(3); + $nameAnswer->setText('Alice'); + + $emailAnswer = $this->makeEmailAnswer(10, 5, 'alice@example.com'); + + $this->answerMapper->method('findBySubmission')->willReturn([$nameAnswer, $emailAnswer]); + $this->emailValidator->method('isValid')->willReturn(true); + + $this->cache->method('add')->willReturn(true); + + $this->jobList->expects($this->once()) + ->method('add') + ->with( + SendConfirmationMailJob::class, + $this->callback(function (array $payload): bool { + $this->assertSame('Hi Alice', $payload['subject']); + $this->assertSame('Your answer: alice@example.com', $payload['body']); + return true; + }) + ); + + $this->service->send($form, $submission); + } + + public function testSendDoesNothingWhenRateLimitExceeded(): void { + $form = Form::fromParams([ + 'id' => 1, + 'confirmationEmailEnabled' => true, + 'confirmationEmailQuestionId' => 5, + ]); + $submission = new Submission(); + $submission->setId(10); + + $this->configService->method('getAllowConfirmationEmail')->willReturn(true); + $this->configService->method('getConfirmationEmailRateLimit')->willReturn(3); + $this->questionMapper->method('findByForm')->willReturn([ + $this->createQuestionEntity([ + 'id' => 5, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'extraSettings' => ['validationType' => 'email'], + ]), + ]); + $this->answerMapper->method('findBySubmission')->willReturn([ + $this->makeEmailAnswer(10, 5, 'user@example.com'), + ]); + $this->emailValidator->method('isValid')->willReturn(true); + + $this->cache->method('add')->willReturn(false); + $this->cache->method('inc')->willReturn(4); // exceeds limit of 3 + + $this->jobList->expects($this->never())->method('add'); + + $this->service->send($form, $submission); + } + + public function testSendSkipsRateLimitWhenCacheIsNotIMemcache(): void { + $plainCache = $this->createMock(ICache::class); + $this->cacheFactory = $this->createMock(ICacheFactory::class); + $this->cacheFactory->method('createDistributed')->willReturn($plainCache); + + $service = new ConfirmationEmailService( + $this->configService, + $this->answerMapper, + $this->questionMapper, + $this->emailValidator, + $this->jobList, + $this->cacheFactory, + $this->l10n, + $this->logger, + ); + + $form = Form::fromParams([ + 'id' => 1, + 'title' => 'Test', + 'confirmationEmailEnabled' => true, + 'confirmationEmailQuestionId' => 5, + ]); + $submission = new Submission(); + $submission->setId(10); + + $this->configService->method('getAllowConfirmationEmail')->willReturn(true); + $this->questionMapper->method('findByForm')->willReturn([ + $this->createQuestionEntity([ + 'id' => 5, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'extraSettings' => ['validationType' => 'email'], + ]), + ]); + $this->answerMapper->method('findBySubmission')->willReturn([ + $this->makeEmailAnswer(10, 5, 'user@example.com'), + ]); + $this->emailValidator->method('isValid')->willReturn(true); + + $this->jobList->expects($this->once())->method('add'); + + $service->send($form, $submission); + } + + public function testValidateRecipientQuestionIdAllowsNull(): void { + $form = new Form(); + $this->service->validateRecipientQuestionId($form, null); + $this->assertTrue(true); + } + + public function testValidateRecipientQuestionIdRejectsNonInt(): void { + $form = new Form(); + $this->expectException(\InvalidArgumentException::class); + $this->service->validateRecipientQuestionId($form, '7'); + } + + public function testValidateRecipientQuestionIdRejectsNotFoundQuestion(): void { + $form = new Form(); + $this->questionMapper->method('findById') + ->willThrowException(new DoesNotExistException('')); + $this->expectException(\InvalidArgumentException::class); + $this->service->validateRecipientQuestionId($form, 7); + } + + public function testValidateRecipientQuestionIdRejectsMismatchedForm(): void { + $form = new Form(); + $form->setId(1); + + $question = new Question(); + $question->setFormId(2); + $question->setOrder(1); + $question->setType(Constants::ANSWER_TYPE_SHORT); + $question->setExtraSettings(['validationType' => 'email']); + + $this->questionMapper->method('findById')->willReturn($question); + $this->expectException(\InvalidArgumentException::class); + $this->service->validateRecipientQuestionId($form, 7); + } + + public function testValidateRecipientQuestionIdRejectsDeletedQuestion(): void { + $form = new Form(); + $form->setId(1); + + $question = new Question(); + $question->setFormId(1); + $question->setOrder(0); + $question->setType(Constants::ANSWER_TYPE_SHORT); + $question->setExtraSettings(['validationType' => 'email']); + + $this->questionMapper->method('findById')->willReturn($question); + $this->expectException(\InvalidArgumentException::class); + $this->service->validateRecipientQuestionId($form, 7); + } + + public function testValidateRecipientQuestionIdRejectsNonEmailQuestion(): void { + $form = new Form(); + $form->setId(1); + + $question = new Question(); + $question->setFormId(1); + $question->setOrder(1); + $question->setType(Constants::ANSWER_TYPE_LONG); + + $this->questionMapper->method('findById')->willReturn($question); + $this->expectException(\InvalidArgumentException::class); + $this->service->validateRecipientQuestionId($form, 7); + } + + public function testValidateRecipientQuestionIdAllowsValidQuestion(): void { + $form = new Form(); + $form->setId(1); + + $question = new Question(); + $question->setFormId(1); + $question->setOrder(1); + $question->setType(Constants::ANSWER_TYPE_SHORT); + $question->setExtraSettings(['validationType' => 'email']); + + $this->questionMapper->method('findById')->willReturn($question); + $this->service->validateRecipientQuestionId($form, 7); + $this->assertTrue(true); + } +} diff --git a/tests/Unit/Service/FormsServiceTest.php b/tests/Unit/Service/FormsServiceTest.php index 0ae0378de..a62f2b80a 100644 --- a/tests/Unit/Service/FormsServiceTest.php +++ b/tests/Unit/Service/FormsServiceTest.php @@ -44,6 +44,7 @@ function microtime(bool|float $asFloat = false) { use OCA\Forms\Db\SubmissionMapper; use OCA\Forms\Service\CirclesService; use OCA\Forms\Service\ConfigService; +use OCA\Forms\Service\ConfirmationEmailService; use OCA\Forms\Service\FormsService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\IEventDispatcher; @@ -111,6 +112,9 @@ class FormsServiceTest extends TestCase { /** @var LoggerInterface|MockObject */ private $logger; + /** @var ConfirmationEmailService|MockObject */ + private $confirmationEmailService; + public function setUp(): void { parent::setUp(); $this->activityManager = $this->createMock(ActivityManager::class); @@ -121,6 +125,7 @@ public function setUp(): void { $this->submissionMapper = $this->createMock(SubmissionMapper::class); $this->configService = $this->createMock(ConfigService::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->confirmationEmailService = $this->createMock(ConfirmationEmailService::class); $this->groupManager = $this->createMock(IGroupManager::class); $this->userManager = $this->createMock(IUserManager::class); $this->secureRandom = $this->createMock(ISecureRandom::class); @@ -159,10 +164,35 @@ public function setUp(): void { $this->l10n, $this->logger, \OCP\Server::get(IEventDispatcher::class), - $this->logger, + $this->confirmationEmailService, ); } + private function createFormsServiceWithEventDispatcher(IEventDispatcher $eventDispatcher): FormsService { + return $this->getMockBuilder(FormsService::class) + ->onlyMethods(['getShares']) + ->setConstructorArgs([ + $this->createMock(IUserSession::class), + $this->activityManager, + $this->formMapper, + $this->optionMapper, + $this->questionMapper, + $this->shareMapper, + $this->submissionMapper, + $this->configService, + $this->groupManager, + $this->userManager, + $this->secureRandom, + $this->circlesService, + $this->storage, + $this->l10n, + $this->logger, + $eventDispatcher, + $this->confirmationEmailService, + ]) + ->getMock(); + } + public function testGenerateFormHash() { $this->secureRandom->expects($this->once()) ->method('generate') @@ -256,6 +286,10 @@ public static function dataGetForm() { 'lockedUntil' => null, 'maxSubmissions' => null, 'isMaxSubmissionsReached' => false, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, + 'confirmationEmailQuestionId' => null, ]] ]; } @@ -473,6 +507,10 @@ public static function dataGetPublicForm() { 'lockedUntil' => null, 'maxSubmissions' => null, 'isMaxSubmissionsReached' => false, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, + 'confirmationEmailQuestionId' => null, ]] ]; } @@ -640,7 +678,7 @@ public function testGetPermissions_NotLoggedIn() { $this->l10n, $this->logger, \OCP\Server::get(IEventDispatcher::class), - $this->logger, + $this->confirmationEmailService, ); $form = new Form(); @@ -865,7 +903,7 @@ public function testPublicCanSubmit() { $this->l10n, $this->logger, \OCP\Server::get(IEventDispatcher::class), - $this->logger, + $this->confirmationEmailService, ); $this->assertEquals(true, $formsService->canSubmit($form)); @@ -973,7 +1011,7 @@ public function testHasUserAccess_NotLoggedIn() { $this->l10n, $this->logger, \OCP\Server::get(IEventDispatcher::class), - $this->logger, + $this->confirmationEmailService, ); $form = new Form(); @@ -1196,7 +1234,7 @@ public function testNotifyNewSubmission($shares, $shareNotifications) { $this->l10n, $this->logger, $eventDispatcher, - $this->logger, + $this->confirmationEmailService, ]) ->getMock(); @@ -1213,6 +1251,8 @@ public function testNotifyNewSubmission($shares, $shareNotifications) { $eventDispatcher->expects($this->exactly(1))->method('dispatchTyped')->withAnyParameters(); + $this->confirmationEmailService->expects($this->once())->method('send')->with($form, $submission); + $formsService->notifyNewSubmission($form, $submission); } @@ -1553,9 +1593,10 @@ public function testGetQuestionsHandlesDoesNotExistException(): void { $this->assertEmpty($result); } - private function createQuestionEntity(array $data): Question { + private function createQuestionEntity(array $data): MockObject { $questionEntity = $this->createMock(Question::class); $questionEntity->method('read')->willReturn($data); return $questionEntity; } + }