From 78fcc90dc709e14827ac3bfa4fac2072650cd90a Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 31 Mar 2026 22:03:40 +0200 Subject: [PATCH 1/2] feat(forms): add respondent confirmation emails Signed-off-by: Robert --- docs/API_v3.md | 5 +- docs/DataStructure.md | 1 + lib/AppInfo/Application.php | 3 + lib/Constants.php | 1 + lib/Controller/ApiController.php | 41 ++- lib/Events/FormSubmittedEvent.php | 17 + lib/Listener/ConfirmationEmailListener.php | 106 +++++++ lib/ResponseDefinitions.php | 4 +- lib/Service/ConfirmationMailService.php | 96 ++++++ lib/Service/FormsService.php | 17 +- openapi.json | 21 +- src/components/Questions/QuestionShort.vue | 28 ++ tests/Integration/Api/ApiV3Test.php | 2 +- .../Api/RespectAdminSettingsTest.php | 2 +- tests/Integration/IntegrationBase.php | 4 +- tests/Unit/Controller/ApiControllerTest.php | 6 +- .../ConfirmationEmailListenerTest.php | 290 ++++++++++++++++++ tests/Unit/Service/FormsServiceTest.php | 23 ++ 18 files changed, 635 insertions(+), 32 deletions(-) create mode 100644 lib/Listener/ConfirmationEmailListener.php create mode 100644 lib/Service/ConfirmationMailService.php create mode 100644 tests/Unit/Listener/ConfirmationEmailListenerTest.php diff --git a/docs/API_v3.md b/docs/API_v3.md index 40c61d54c..54dbf7a91 100644 --- a/docs/API_v3.md +++ b/docs/API_v3.md @@ -436,7 +436,9 @@ Update a single or multiple properties of a question-object. | Parameter | Type | Description | |-----------|---------|-------------| | _keyValuePairs_ | Array | Array of key-value pairs to update | -- Restrictions: It is **not allowed** to update one of the following key-value pairs: _id, formId, order_. +- Restrictions: + - It is **not allowed** to update one of the following key-value pairs: _id, formId, order_. + - `extraSettings.confirmationRecipient` can only be enabled for short questions with `extraSettings.validationType` set to `email`. - Response: **Status-Code OK**, as well as the id of the updated question. ``` @@ -901,6 +903,7 @@ Store Submission to Database - An **array** of values as value --> Even for short Text Answers, wrapped into Array. - For Question-Types with pre-defined answers (`multiple`, `multiple_unique`, `dropdown`), the array contains the corresponding option-IDs. - For File-Uploads, the array contains the objects with key `uploadedFileId` (value from Upload a file endpoint). + - To send a respondent confirmation email, set the corresponding short question to `validationType = email` and `confirmationRecipient = true`. ``` { diff --git a/docs/DataStructure.md b/docs/DataStructure.md index a4dc03c64..d21726bfb 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -240,6 +240,7 @@ Optional extra settings for some [Question Types](#question-types) | `optionsLimitMin` | `multiple` | Integer | - | Minimum number of options that must be selected | | `validationType` | `short` | string | `null, 'phone', 'email', 'regex', 'number'` | Custom validation for checking a submission | | `validationRegex` | `short` | string | regular expression | if `validationType` is 'regex' this defines the regular expression to apply | +| `confirmationRecipient` | `short` | Boolean | `true/false` | Marks an email question as recipient for respondent confirmation emails | | `allowedFileTypes` | `file` | Array of strings | `'image', 'x-office/document'` | Allowed file types for file upload | | `allowedFileExtensions` | `file` | Array of strings | `'jpg', 'png'` | Allowed file extensions for file upload | | `maxAllowedFilesCount` | `file` | Integer | - | Maximum number of files that can be uploaded, 0 means no limit | diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 82c17f12f..d572ce3a7 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -11,8 +11,10 @@ use OCA\Analytics\Datasource\DatasourceEvent; use OCA\Forms\Capabilities; +use OCA\Forms\Events\FormSubmittedEvent; use OCA\Forms\FormsMigrator; use OCA\Forms\Listener\AnalyticsDatasourceListener; +use OCA\Forms\Listener\ConfirmationEmailListener; use OCA\Forms\Listener\UserDeletedListener; use OCA\Forms\Middleware\ThrottleFormAccessMiddleware; use OCA\Forms\Search\SearchProvider; @@ -43,6 +45,7 @@ public function register(IRegistrationContext $context): void { $context->registerCapability(Capabilities::class); $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); + $context->registerEventListener(FormSubmittedEvent::class, ConfirmationEmailListener::class); $context->registerEventListener(DatasourceEvent::class, AnalyticsDatasourceListener::class); $context->registerMiddleware(ThrottleFormAccessMiddleware::class); $context->registerSearchProvider(SearchProvider::class); diff --git a/lib/Constants.php b/lib/Constants.php index 3cb470193..0b77cc6f4 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -149,6 +149,7 @@ class Constants { public const EXTRA_SETTINGS_SHORT = [ 'validationType' => ['string'], 'validationRegex' => ['string'], + 'confirmationRecipient' => ['boolean'], ]; public const EXTRA_SETTINGS_FILE = [ diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 0cbe6a73e..b6b1aebb8 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -22,6 +22,7 @@ use OCA\Forms\Db\SubmissionMapper; use OCA\Forms\Db\UploadedFile; use OCA\Forms\Db\UploadedFileMapper; +use OCA\Forms\Events\FormSubmittedEvent; use OCA\Forms\Exception\NoSuchFormException; use OCA\Forms\ResponseDefinitions; use OCA\Forms\Service\ConfigService; @@ -546,6 +547,10 @@ public function newQuestion(int $formId, ?string $type = null, ?string $subtype $questionData = $sourceQuestion->read(); unset($questionData['id']); $questionData['order'] = end($allQuestions)->getOrder() + 1; + if (is_array($questionData['extraSettings'] ?? null) + && ($questionData['extraSettings']['confirmationRecipient'] ?? false) === true) { + $questionData['extraSettings']['confirmationRecipient'] = false; + } $newQuestion = Question::fromParams($questionData); $this->questionMapper->insert($newQuestion); @@ -648,6 +653,12 @@ public function updateQuestion(int $formId, int $questionId, array $keyValuePair if (key_exists('extraSettings', $keyValuePairs) && !$this->formsService->areExtraSettingsValid($keyValuePairs['extraSettings'], $question->getType())) { throw new OCSBadRequestException('Invalid extraSettings, will not update.'); } + $this->assertSingleConfirmationRecipientQuestion( + $formId, + $questionId, + $question->getType(), + is_array($keyValuePairs['extraSettings'] ?? null) ? $keyValuePairs['extraSettings'] : null, + ); // Create QuestionEntity with given Params & Id. $question = Question::fromParams($keyValuePairs); @@ -1405,7 +1416,7 @@ public function newSubmission(int $formId, array $answers, string $shareHash = ' $this->formMapper->update($form); //Create Activity - $this->formsService->notifyNewSubmission($form, $submission); + $this->formsService->notifyNewSubmission($form, $submission, FormSubmittedEvent::TRIGGER_CREATED); if ($form->getFileId() !== null) { $this->jobList->add(SyncSubmissionsWithLinkedFileJob::class, ['form_id' => $form->getId()]); @@ -1487,7 +1498,7 @@ public function updateSubmission(int $formId, int $submissionId, array $answers) } //Create Activity - $this->formsService->notifyNewSubmission($form, $submission); + $this->formsService->notifyNewSubmission($form, $submission, FormSubmittedEvent::TRIGGER_UPDATED); return new DataResponse($submissionId); } @@ -1827,6 +1838,32 @@ private function checkAccessUpdate(array $keyValuePairs): void { } } + /** + * Ensure only one short-email question can be used as confirmation recipient in a form. + * + * @param array|null $extraSettings + */ + private function assertSingleConfirmationRecipientQuestion(int $formId, int $questionId, string $questionType, ?array $extraSettings): void { + if ($questionType !== Constants::ANSWER_TYPE_SHORT || !is_array($extraSettings) || ($extraSettings['confirmationRecipient'] ?? false) !== true) { + return; + } + + $formQuestions = $this->questionMapper->findByForm($formId); + foreach ($formQuestions as $formQuestion) { + if ($formQuestion->getId() === $questionId) { + continue; + } + + $existingSettings = $formQuestion->getExtraSettings(); + $isExistingConfirmationRecipient = $formQuestion->getType() === Constants::ANSWER_TYPE_SHORT + && ($existingSettings['validationType'] ?? null) === 'email' + && ($existingSettings['confirmationRecipient'] ?? false) === true; + if ($isExistingConfirmationRecipient) { + throw new OCSBadRequestException('Only one confirmation recipient question is allowed per form'); + } + } + } + /** * Checks if the current user is allowed to archive/unarchive the form */ diff --git a/lib/Events/FormSubmittedEvent.php b/lib/Events/FormSubmittedEvent.php index dde7ec158..ed01a5080 100644 --- a/lib/Events/FormSubmittedEvent.php +++ b/lib/Events/FormSubmittedEvent.php @@ -11,17 +11,34 @@ use OCA\Forms\Db\Submission; class FormSubmittedEvent extends AbstractFormEvent { + public const TRIGGER_CREATED = 'created'; + public const TRIGGER_UPDATED = 'updated'; + public function __construct( Form $form, private Submission $submission, + private string $trigger = self::TRIGGER_CREATED, ) { parent::__construct($form); } + public function getSubmission(): Submission { + return $this->submission; + } + + public function getTrigger(): string { + return $this->trigger; + } + + public function isNewSubmission(): bool { + return $this->trigger === self::TRIGGER_CREATED; + } + public function getWebhookSerializable(): array { return [ 'form' => $this->form->read(), 'submission' => $this->submission->read(), + 'trigger' => $this->trigger, ]; } } diff --git a/lib/Listener/ConfirmationEmailListener.php b/lib/Listener/ConfirmationEmailListener.php new file mode 100644 index 000000000..7c17b9155 --- /dev/null +++ b/lib/Listener/ConfirmationEmailListener.php @@ -0,0 +1,106 @@ + + */ +class ConfirmationEmailListener implements IEventListener { + public function __construct( + private ConfirmationMailService $confirmationMailService, + private AnswerMapper $answerMapper, + private QuestionMapper $questionMapper, + private LoggerInterface $logger, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof FormSubmittedEvent)) { + return; + } + if (!$event->isNewSubmission()) { + return; + } + + $submission = $event->getSubmission(); + $form = $event->getForm(); + + $emailAddress = null; + $answerSummaries = []; + try { + $answers = $this->answerMapper->findBySubmission($submission->getId()); + } catch (DoesNotExistException $e) { + return; + } + $hasAmbiguousRecipients = false; + + foreach ($answers as $answer) { + try { + $question = $this->questionMapper->findById($answer->getQuestionId()); + } catch (DoesNotExistException $e) { + $this->logger->warning('Question missing while preparing confirmation mail', [ + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + 'questionId' => $answer->getQuestionId(), + ]); + continue; + } + + $questionType = $question->getType(); + $answerText = trim($answer->getText() ?? ''); + + $extraSettings = $question->getExtraSettings(); + $isEmailQuestion = $questionType === Constants::ANSWER_TYPE_SHORT + && (($extraSettings['validationType'] ?? null) === 'email'); + $isConfirmationRecipient = ($extraSettings['confirmationRecipient'] ?? false) === true; + + if ($answerText !== '' && $isEmailQuestion && $isConfirmationRecipient) { + if ($emailAddress !== null && !hash_equals($emailAddress, $answerText)) { + $hasAmbiguousRecipients = true; + break; + } + $emailAddress = $answerText; + } + + if ( + $answerText !== '' + && in_array($questionType, [Constants::ANSWER_TYPE_SHORT, Constants::ANSWER_TYPE_LONG], true) + ) { + $answerSummaries[] = [ + 'question' => $question->getText(), + 'answer' => $answerText, + ]; + } + } + if ($hasAmbiguousRecipients) { + $this->logger->warning('Skipping confirmation mail because multiple confirmation recipient questions were answered', [ + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + ]); + return; + } + + if ($emailAddress === null) { + return; + } + + $this->confirmationMailService->send($form, $submission, $emailAddress, $answerSummaries); + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 27fea25bb..272d58818 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -40,6 +40,7 @@ * timeRange?: bool, * validationRegex?: string, * validationType?: string, + * confirmationRecipient?: bool, * questionType?: string, * } * @@ -110,7 +111,6 @@ * state: int, * lockedBy: ?string, * lockedUntil: ?int, - * maxSubmissions: ?int, * } * * @psalm-type FormsForm = array{ @@ -126,7 +126,6 @@ * fileId: ?int, * filePath?: ?string, * isAnonymous: bool, - * isMaxSubmissionsReached: bool, * lastUpdated: int, * submitMultiple: bool, * allowEditSubmissions: bool, @@ -137,7 +136,6 @@ * state: 0|1|2, * lockedBy: ?string, * lockedUntil: ?int, - * maxSubmissions: ?int, * shares: list, * submissionCount?: int, * submissionMessage: ?string, diff --git a/lib/Service/ConfirmationMailService.php b/lib/Service/ConfirmationMailService.php new file mode 100644 index 000000000..2f8466271 --- /dev/null +++ b/lib/Service/ConfirmationMailService.php @@ -0,0 +1,96 @@ + $answerSummaries + */ + public function send(Form $form, Submission $submission, string $recipient, array $answerSummaries = []): void { + if (!$this->mailer->validateMailAddress($recipient)) { + $this->logger->debug('Skipping confirmation mail, invalid recipient address', [ + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + ]); + return; + } + + $formTitle = $form->getTitle(); + $subject = $this->l10n->t('Confirmation for your response to %s', [$formTitle]); + + try { + // Create styled email template with Nextcloud branding + $emailTemplate = $this->mailer->createEMailTemplate('forms.ConfirmationEmail', [ + 'formTitle' => $formTitle, + ]); + + $emailTemplate->setSubject($subject); + + // Add header with Nextcloud logo + $emailTemplate->addHeader(); + + // Add heading + $emailTemplate->addHeading($this->l10n->t('Form Submission Confirmed')); + + // Add body text + $emailTemplate->addBodyText( + $this->l10n->t('Thank you for submitting the form %s. We have successfully received your response.', [$formTitle]) + ); + + // Add submission summary if available + if ($answerSummaries !== []) { + $emailTemplate->addBodyText($this->l10n->t('Your responses:')); + + foreach ($answerSummaries as $summary) { + $emailTemplate->addBodyListItem( + $summary['answer'], + $summary['question'], + '', + '', + '' + ); + } + } + + // Add footer + $emailTemplate->addFooter( + $this->l10n->t('This message was sent automatically by %s.', [$this->l10n->t('Forms')]) + ); + + $message = $this->mailer->createMessage(); + $message->setAutoSubmitted(AutoSubmitted::VALUE_AUTO_GENERATED); + $message->setSubject($subject); + $message->setTo([$recipient]); + $message->useTemplate($emailTemplate); + + $this->mailer->send($message); + } catch (\Throwable $e) { + $this->logger->error('Failed to send confirmation email for submission', [ + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + 'exception' => $e, + ]); + } + } +} diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index f5b421c3a..0cd68222d 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -709,7 +709,7 @@ public function notifyNewShares(Form $form, Share $share): void { * @param Form $form Related Form * @param string $submitter The ID of the user who submitted the form. Can also be our 'anon-user-'-ID */ - public function notifyNewSubmission(Form $form, Submission $submission): void { + public function notifyNewSubmission(Form $form, Submission $submission, string $trigger = FormSubmittedEvent::TRIGGER_CREATED): void { $shares = $this->getShares($form->getId()); try { $this->activityManager->publishNewSubmission($form, $submission->getUserId()); @@ -738,7 +738,7 @@ public function notifyNewSubmission(Form $form, Submission $submission): void { } } - $this->eventDispatcher->dispatchTyped(new FormSubmittedEvent($form, $submission)); + $this->eventDispatcher->dispatchTyped(new FormSubmittedEvent($form, $submission, $trigger)); } /** @@ -874,7 +874,18 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType } // Special handling of short input for validation - } elseif ($questionType === Constants::ANSWER_TYPE_SHORT && isset($extraSettings['validationType'])) { + } elseif ($questionType === Constants::ANSWER_TYPE_SHORT) { + if (isset($extraSettings['confirmationRecipient'])) { + // Confirmation recipients must be explicit email fields + if (($extraSettings['validationType'] ?? null) !== 'email') { + return false; + } + } + + if (!isset($extraSettings['validationType'])) { + return true; + } + // Ensure input validation type is known if (!in_array($extraSettings['validationType'], Constants::SHORT_INPUT_TYPES)) { return false; diff --git a/openapi.json b/openapi.json index 9d80a1151..e9f60c4e8 100644 --- a/openapi.json +++ b/openapi.json @@ -106,7 +106,6 @@ "fileFormat", "fileId", "isAnonymous", - "isMaxSubmissionsReached", "lastUpdated", "submitMultiple", "allowEditSubmissions", @@ -117,7 +116,6 @@ "state", "lockedBy", "lockedUntil", - "maxSubmissions", "shares", "submissionMessage" ], @@ -165,9 +163,6 @@ "isAnonymous": { "type": "boolean" }, - "isMaxSubmissionsReached": { - "type": "boolean" - }, "lastUpdated": { "type": "integer", "format": "int64" @@ -214,11 +209,6 @@ "format": "int64", "nullable": true }, - "maxSubmissions": { - "type": "integer", - "format": "int64", - "nullable": true - }, "shares": { "type": "array", "items": { @@ -317,8 +307,7 @@ "partial", "state", "lockedBy", - "lockedUntil", - "maxSubmissions" + "lockedUntil" ], "properties": { "id": { @@ -359,11 +348,6 @@ "type": "integer", "format": "int64", "nullable": true - }, - "maxSubmissions": { - "type": "integer", - "format": "int64", - "nullable": true } } }, @@ -539,6 +523,9 @@ "validationType": { "type": "string" }, + "confirmationRecipient": { + "type": "boolean" + }, "questionType": { "type": "string" } diff --git a/src/components/Questions/QuestionShort.vue b/src/components/Questions/QuestionShort.vue index d770520d7..5bd028f80 100644 --- a/src/components/Questions/QuestionShort.vue +++ b/src/components/Questions/QuestionShort.vue @@ -53,6 +53,17 @@ @update:model-value="onChangeValidationType(validationTypeName)"> {{ validationTypeObject.label }} + + {{ + t( + 'forms', + 'Use this question as confirmation email recipient', + ) + }} +