diff --git a/docs/API_v3.md b/docs/API_v3.md index 40c61d54c..69f4b050e 100644 --- a/docs/API_v3.md +++ b/docs/API_v3.md @@ -436,7 +436,10 @@ 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`. + - `extraSettings.requireEmailVerification` can only be enabled for short questions with `extraSettings.validationType` set to `email` and `extraSettings.confirmationRecipient` set to `true`. - Response: **Status-Code OK**, as well as the id of the updated question. ``` @@ -901,6 +904,8 @@ 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`. + - If the same question also sets `requireEmailVerification = true`, the submission stays pending until the recipient visits the verification link, and the confirmation email is only sent after verification succeeds. ``` { diff --git a/docs/DataStructure.md b/docs/DataStructure.md index a4dc03c64..46dc7730e 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -148,6 +148,7 @@ A submission-object describes a single submission by a user to a form. | formId | Integer | | The id of the form, the submission belongs to | | userId | String | | The nextcloud userId of the submitting user. If submission is anonymous, this contains `anon-user-` | | timestamp | unix timestamp | | When the user submitted | +| isVerified | Boolean | | Whether the submission has completed email-address verification, if required | | answers | Array of [Answers](#answer) | | Array of the actual user answers, belonging to this submission. | userDisplayName | String | | Display name of the nextcloud-user, derived from `userId`. Contains `Anonymous user` if submitted anonymously. Not stored in DB. @@ -157,6 +158,7 @@ A submission-object describes a single submission by a user to a form. "formId": 3, "userId": "jonas", "timestamp": 1611274433, + "isVerified": true, "answers": [], "userDisplayName": "jonas" } @@ -240,6 +242,8 @@ 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 | +| `requireEmailVerification` | `short` | Boolean | `true/false` | Requires respondents to verify the confirmation-recipient email before the submission is treated as verified | | `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..ec75e7074 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -11,8 +11,11 @@ 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\SubmissionVerificationListener; use OCA\Forms\Listener\UserDeletedListener; use OCA\Forms\Middleware\ThrottleFormAccessMiddleware; use OCA\Forms\Search\SearchProvider; @@ -43,6 +46,8 @@ public function register(IRegistrationContext $context): void { $context->registerCapability(Capabilities::class); $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); + $context->registerEventListener(FormSubmittedEvent::class, SubmissionVerificationListener::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..ea05413bb 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -149,6 +149,8 @@ class Constants { public const EXTRA_SETTINGS_SHORT = [ 'validationType' => ['string'], 'validationRegex' => ['string'], + 'confirmationRecipient' => ['boolean'], + 'requireEmailVerification' => ['boolean'], ]; public const EXTRA_SETTINGS_FILE = [ diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 0cbe6a73e..6d388a39a 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,13 @@ 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; + if (($questionData['extraSettings']['requireEmailVerification'] ?? false) === true) { + $questionData['extraSettings']['requireEmailVerification'] = false; + } + } $newQuestion = Question::fromParams($questionData); $this->questionMapper->insert($newQuestion); @@ -648,6 +656,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); @@ -1360,6 +1374,7 @@ public function newSubmission(int $formId, array $answers, string $shareHash = ' $submission = new Submission(); $submission->setFormId($formId); $submission->setTimestamp(time()); + $submission->setIsVerified(true); // If not logged in, anonymous, or embedded use anonID if (!$this->currentUser || $form->getIsAnonymous()) { @@ -1405,7 +1420,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 +1502,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 +1842,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/Controller/PageController.php b/lib/Controller/PageController.php index 43db2366a..a25406337 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -14,6 +14,7 @@ use OCA\Forms\Db\SubmissionMapper; use OCA\Forms\Service\ConfigService; use OCA\Forms\Service\FormsService; +use OCA\Forms\Service\SubmissionVerificationService; use OCP\Accounts\IAccountManager; use OCP\AppFramework\Controller; @@ -49,6 +50,7 @@ public function __construct( private SubmissionMapper $submissionMapper, private ConfigService $configService, private FormsService $formsService, + private SubmissionVerificationService $submissionVerificationService, private IAccountManager $accountManager, private IInitialState $initialState, private IL10N $l10n, @@ -118,6 +120,27 @@ public function submitViewWithSubmission(string $hash, int $submissionId): Templ return $this->formMapper->findByHash($hash)->getAllowEditSubmissions() ? $this->index($hash, $submissionId) : $this->index($hash); } + #[NoAdminRequired()] + #[NoCSRFRequired()] + #[PublicPage()] + #[FrontpageRoute(verb: 'GET', url: '/verify/{token}', requirements: ['token' => '[a-f0-9]{48}'])] + public function verifySubmissionEmail(string $token): PublicTemplateResponse { + $isVerified = $this->submissionVerificationService->verifyToken($token); + + $response = new PublicTemplateResponse($this->appName, 'verify', [ + 'verified' => $isVerified, + 'headline' => $isVerified + ? $this->l10n->t('Email address verified') + : $this->l10n->t('Email verification failed'), + 'message' => $isVerified + ? $this->l10n->t('Your email address has been verified successfully. You can close this page now.') + : $this->l10n->t('The verification link is invalid or expired.'), + ]); + $response->setHeaderTitle($this->l10n->t('Forms')); + + return $response; + } + /** * @param string $hash * @return RedirectResponse|TemplateResponse Redirect to login or internal view. diff --git a/lib/Db/Submission.php b/lib/Db/Submission.php index 54b651aea..c59df49a8 100644 --- a/lib/Db/Submission.php +++ b/lib/Db/Submission.php @@ -18,11 +18,14 @@ * @method void setUserId(string $value) * @method int getTimestamp() * @method void setTimestamp(integer $value) + * @method bool getIsVerified() + * @method void setIsVerified(bool $value) */ class Submission extends Entity { protected $formId; protected $userId; protected $timestamp; + protected $isVerified; /** * Submission constructor. @@ -30,6 +33,7 @@ class Submission extends Entity { public function __construct() { $this->addType('formId', 'integer'); $this->addType('timestamp', 'integer'); + $this->addType('isVerified', 'boolean'); } /** @@ -38,6 +42,7 @@ public function __construct() { * formId: int, * userId: string, * timestamp: int, + * isVerified: bool, * } */ public function read(): array { @@ -46,6 +51,7 @@ public function read(): array { 'formId' => $this->getFormId(), 'userId' => $this->getUserId(), 'timestamp' => $this->getTimestamp(), + 'isVerified' => (bool)$this->getIsVerified(), ]; } } diff --git a/lib/Db/SubmissionVerification.php b/lib/Db/SubmissionVerification.php new file mode 100644 index 000000000..727e8968e --- /dev/null +++ b/lib/Db/SubmissionVerification.php @@ -0,0 +1,59 @@ +addType('submissionId', 'integer'); + $this->addType('expires', 'integer'); + $this->addType('used', 'integer'); + } + + /** + * @return array{ + * id: int, + * submissionId: int, + * recipientEmailHash: string, + * tokenHash: string, + * expires: int, + * used: int|null, + * } + */ + public function read(): array { + return [ + 'id' => $this->getId(), + 'submissionId' => $this->getSubmissionId(), + 'recipientEmailHash' => $this->getRecipientEmailHash(), + 'tokenHash' => $this->getTokenHash(), + 'expires' => $this->getExpires(), + 'used' => $this->getUsed(), + ]; + } +} diff --git a/lib/Db/SubmissionVerificationMapper.php b/lib/Db/SubmissionVerificationMapper.php new file mode 100644 index 000000000..c974d4fa0 --- /dev/null +++ b/lib/Db/SubmissionVerificationMapper.php @@ -0,0 +1,55 @@ + + */ +class SubmissionVerificationMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'forms_v2_submission_verify', SubmissionVerification::class); + } + + /** + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + */ + public function findBySubmissionId(int $submissionId): SubmissionVerification { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('submission_id', $qb->createNamedParameter($submissionId, IQueryBuilder::PARAM_INT)) + ); + + return $this->findEntity($qb); + } + + /** + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + */ + public function findByTokenHash(string $tokenHash): SubmissionVerification { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('token_hash', $qb->createNamedParameter($tokenHash, IQueryBuilder::PARAM_STR)) + ); + + return $this->findEntity($qb); + } +} diff --git a/lib/Events/FormSubmittedEvent.php b/lib/Events/FormSubmittedEvent.php index dde7ec158..7f2923473 100644 --- a/lib/Events/FormSubmittedEvent.php +++ b/lib/Events/FormSubmittedEvent.php @@ -11,17 +11,35 @@ use OCA\Forms\Db\Submission; class FormSubmittedEvent extends AbstractFormEvent { + public const TRIGGER_CREATED = 'created'; + public const TRIGGER_UPDATED = 'updated'; + public const TRIGGER_VERIFIED = 'verified'; + 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/FormsMigrator.php b/lib/FormsMigrator.php index 5a74db027..6bb92355f 100644 --- a/lib/FormsMigrator.php +++ b/lib/FormsMigrator.php @@ -182,6 +182,7 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface $submission->setFormId($form->getId()); $submission->setUserId($submissionData['userId']); $submission->setTimestamp($submissionData['timestamp']); + $submission->setIsVerified($submissionData['isVerified'] ?? true); $this->submissionMapper->insert($submission); diff --git a/lib/Listener/ConfirmationEmailListener.php b/lib/Listener/ConfirmationEmailListener.php new file mode 100644 index 000000000..de5dae4b6 --- /dev/null +++ b/lib/Listener/ConfirmationEmailListener.php @@ -0,0 +1,109 @@ + + */ +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 (!in_array($event->getTrigger(), [FormSubmittedEvent::TRIGGER_CREATED, FormSubmittedEvent::TRIGGER_VERIFIED], true)) { + return; + } + + $submission = $event->getSubmission(); + $form = $event->getForm(); + if ($event->getTrigger() === FormSubmittedEvent::TRIGGER_CREATED && $submission->getIsVerified() === false) { + return; + } + + $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/Listener/SubmissionVerificationListener.php b/lib/Listener/SubmissionVerificationListener.php new file mode 100644 index 000000000..ba505a9a5 --- /dev/null +++ b/lib/Listener/SubmissionVerificationListener.php @@ -0,0 +1,115 @@ + + */ +class SubmissionVerificationListener implements IEventListener { + public function __construct( + private AnswerMapper $answerMapper, + private QuestionMapper $questionMapper, + private SubmissionVerificationService $submissionVerificationService, + private SubmissionVerificationMailService $submissionVerificationMailService, + private IMailer $mailer, + private LoggerInterface $logger, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof FormSubmittedEvent)) { + return; + } + if ($event->getTrigger() !== FormSubmittedEvent::TRIGGER_CREATED) { + return; + } + + $form = $event->getForm(); + $submission = $event->getSubmission(); + $emailForVerification = null; + try { + $answers = $this->answerMapper->findBySubmission($submission->getId()); + } catch (DoesNotExistException $e) { + $this->submissionVerificationService->markVerified($submission); + return; + } + + foreach ($answers as $answer) { + try { + $question = $this->questionMapper->findById($answer->getQuestionId()); + } catch (DoesNotExistException $e) { + $this->logger->warning('Question missing while preparing submission verification mail', [ + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + 'questionId' => $answer->getQuestionId(), + ]); + continue; + } + + $extraSettings = $question->getExtraSettings(); + $isVerificationQuestion = $question->getType() === Constants::ANSWER_TYPE_SHORT + && ($extraSettings['validationType'] ?? null) === 'email' + && ($extraSettings['confirmationRecipient'] ?? false) === true + && ($extraSettings['requireEmailVerification'] ?? false) === true; + + if (!$isVerificationQuestion) { + continue; + } + + $answerText = trim($answer->getText() ?? ''); + if ($answerText !== '') { + $emailForVerification = $answerText; + break; + } + } + + if ($emailForVerification === null) { + $this->submissionVerificationService->markVerified($submission); + return; + } + + if (!$this->mailer->validateMailAddress($emailForVerification)) { + $this->logger->warning('Skipping submission verification for invalid email address', [ + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + ]); + return; + } + + try { + $this->submissionVerificationService->markPendingVerification($submission); + $token = $this->submissionVerificationService->createVerificationToken($submission, $emailForVerification); + if ($token === null) { + return; + } + + $verificationLink = $this->submissionVerificationService->createVerificationLink($token); + $this->submissionVerificationMailService->send($form, $submission, $emailForVerification, $verificationLink); + } catch (\Throwable $e) { + $this->logger->error('Failed to process submission verification', [ + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + 'exception' => $e, + ]); + } + } +} diff --git a/lib/Migration/Version050300Date20260228171000.php b/lib/Migration/Version050300Date20260228171000.php new file mode 100644 index 000000000..1145c5510 --- /dev/null +++ b/lib/Migration/Version050300Date20260228171000.php @@ -0,0 +1,92 @@ +getTable('forms_v2_submissions'); + if (!$submissionsTable->hasColumn('is_verified')) { + $submissionsTable->addColumn('is_verified', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => true, + ]); + } + + if (!$schema->hasTable('forms_v2_submission_verify')) { + $verificationTable = $schema->createTable('forms_v2_submission_verify'); + $verificationTable->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $verificationTable->addColumn('submission_id', Types::INTEGER, [ + 'notnull' => true, + ]); + $verificationTable->addColumn('recipient_email_hash', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $verificationTable->addColumn('token_hash', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $verificationTable->addColumn('expires', Types::INTEGER, [ + 'notnull' => true, + 'comment' => 'unix-timestamp', + ]); + $verificationTable->addColumn('used', Types::INTEGER, [ + 'notnull' => false, + 'default' => null, + 'comment' => 'unix-timestamp', + ]); + + $verificationTable->setPrimaryKey(['id'], 'forms_subv_id'); + $verificationTable->addUniqueIndex(['submission_id'], 'forms_subv_sub_id'); + $verificationTable->addUniqueIndex(['token_hash'], 'forms_subv_token_hash'); + } + + return $schema; + } + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $qb = $this->db->getQueryBuilder(); + $qb->update('forms_v2_submissions') + ->set('is_verified', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)) + ->where($qb->expr()->isNull('is_verified')) + ->executeStatement(); + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 27fea25bb..80fd796fa 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -40,6 +40,8 @@ * timeRange?: bool, * validationRegex?: string, * validationType?: string, + * confirmationRecipient?: bool, + * requireEmailVerification?: bool, * questionType?: string, * } * @@ -74,6 +76,7 @@ * formId: int, * userId: string, * timestamp: int, + * isVerified: bool, * answers: list, * userDisplayName: string * } @@ -110,7 +113,6 @@ * state: int, * lockedBy: ?string, * lockedUntil: ?int, - * maxSubmissions: ?int, * } * * @psalm-type FormsForm = array{ @@ -126,7 +128,6 @@ * fileId: ?int, * filePath?: ?string, * isAnonymous: bool, - * isMaxSubmissionsReached: bool, * lastUpdated: int, * submitMultiple: bool, * allowEditSubmissions: bool, @@ -137,7 +138,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..43d779ef2 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,28 @@ 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 (($extraSettings['requireEmailVerification'] ?? false) === true) { + // Verification is only valid for the selected confirmation-recipient email field + if ( + ($extraSettings['validationType'] ?? null) !== 'email' + || ($extraSettings['confirmationRecipient'] ?? false) !== true + ) { + 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/lib/Service/SubmissionVerificationMailService.php b/lib/Service/SubmissionVerificationMailService.php new file mode 100644 index 000000000..0efb0ca9a --- /dev/null +++ b/lib/Service/SubmissionVerificationMailService.php @@ -0,0 +1,73 @@ +mailer->validateMailAddress($recipient)) { + $this->logger->debug('Skipping submission verification mail, invalid recipient address', [ + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + ]); + return; + } + + $formTitle = $form->getTitle(); + $subject = $this->l10n->t('Verify your email for %s', [$formTitle]); + + try { + $emailTemplate = $this->mailer->createEMailTemplate('forms.SubmissionVerificationEmail', [ + 'formTitle' => $formTitle, + ]); + + $emailTemplate->setSubject($subject); + $emailTemplate->addHeader(); + $emailTemplate->addHeading($this->l10n->t('Verify your email address')); + $emailTemplate->addBodyText( + $this->l10n->t('A response was submitted to %s using this email address.', [$formTitle]) + ); + $emailTemplate->addBodyText( + $this->l10n->t('Please verify your email address to confirm ownership of this submission.') + ); + $emailTemplate->addBodyButton($this->l10n->t('Verify email address'), $verificationLink); + $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 submission verification email', [ + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + 'exception' => $e, + ]); + } + } +} diff --git a/lib/Service/SubmissionVerificationService.php b/lib/Service/SubmissionVerificationService.php new file mode 100644 index 000000000..101058d1f --- /dev/null +++ b/lib/Service/SubmissionVerificationService.php @@ -0,0 +1,148 @@ +getIsVerified() === false) { + return; + } + + $submission->setIsVerified(false); + $this->submissionMapper->update($submission); + } + + public function markVerified(Submission $submission): void { + if ($submission->getIsVerified() === true) { + return; + } + + $submission->setIsVerified(true); + $this->submissionMapper->update($submission); + } + + public function createVerificationToken(Submission $submission, string $emailAddress): ?string { + $normalizedRecipientHash = $this->hashRecipient($emailAddress); + $currentTimestamp = time(); + + $verification = null; + try { + $verification = $this->submissionVerificationMapper->findBySubmissionId($submission->getId()); + } catch (DoesNotExistException $e) { + // Fresh token, no pending verification for this submission yet. + } + + if ($verification !== null + && $verification->getUsed() === null + && $verification->getExpires() >= $currentTimestamp + && hash_equals($verification->getRecipientEmailHash(), $normalizedRecipientHash) + ) { + // Avoid duplicate verification mails for unchanged pending verification. + return null; + } + + $token = bin2hex(random_bytes(24)); + $tokenHash = hash('sha256', $token); + + if ($verification === null) { + $verification = new SubmissionVerification(); + $verification->setSubmissionId($submission->getId()); + } + + $verification->setRecipientEmailHash($normalizedRecipientHash); + $verification->setTokenHash($tokenHash); + $verification->setExpires($currentTimestamp + self::TOKEN_VALIDITY_SECONDS); + $verification->setUsed(null); + + if ($verification->getId() === null) { + $this->submissionVerificationMapper->insert($verification); + } else { + $this->submissionVerificationMapper->update($verification); + } + + return $token; + } + + public function createVerificationLink(string $token): string { + return $this->urlGenerator->linkToRouteAbsolute('forms.page.verifySubmissionEmail', [ + 'token' => $token, + ]); + } + + public function verifyToken(string $token): bool { + $tokenHash = hash('sha256', $token); + + try { + $verification = $this->submissionVerificationMapper->findByTokenHash($tokenHash); + } catch (DoesNotExistException $e) { + return false; + } + + $currentTimestamp = time(); + if ($verification->getUsed() !== null || $verification->getExpires() < $currentTimestamp) { + return false; + } + + try { + $submission = $this->submissionMapper->findById($verification->getSubmissionId()); + } catch (DoesNotExistException $e) { + $this->logger->warning('Submission missing while verifying submission email', [ + 'submissionId' => $verification->getSubmissionId(), + ]); + return false; + } + + if ($submission->getIsVerified() === false) { + $submission->setIsVerified(true); + $this->submissionMapper->update($submission); + } + + $verification->setUsed($currentTimestamp); + $this->submissionVerificationMapper->update($verification); + try { + $form = $this->formMapper->findById($submission->getFormId()); + $this->eventDispatcher->dispatchTyped(new FormSubmittedEvent($form, $submission, FormSubmittedEvent::TRIGGER_VERIFIED)); + } catch (DoesNotExistException $e) { + $this->logger->warning('Form missing while dispatching verification-completed submission event', [ + 'formId' => $submission->getFormId(), + 'submissionId' => $submission->getId(), + ]); + } + + return true; + } + + private function hashRecipient(string $emailAddress): string { + return hash('sha256', strtolower(trim($emailAddress))); + } +} diff --git a/openapi.json b/openapi.json index 9d80a1151..eccb80cf8 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,12 @@ "validationType": { "type": "string" }, + "confirmationRecipient": { + "type": "boolean" + }, + "requireEmailVerification": { + "type": "boolean" + }, "questionType": { "type": "string" } @@ -611,6 +601,7 @@ "formId", "userId", "timestamp", + "isVerified", "answers", "userDisplayName" ], @@ -630,6 +621,9 @@ "type": "integer", "format": "int64" }, + "isVerified": { + "type": "boolean" + }, "answers": { "type": "array", "items": { diff --git a/src/components/Questions/QuestionShort.vue b/src/components/Questions/QuestionShort.vue index d770520d7..de0b2406b 100644 --- a/src/components/Questions/QuestionShort.vue +++ b/src/components/Questions/QuestionShort.vue @@ -12,8 +12,11 @@
{{ validationTypeObject.label }} + + {{ + t( + 'forms', + 'Use this question as confirmation email recipient', + ) + }} + + + {{ + t( + 'forms', + 'Require respondents to verify this email address', + ) + }} +