Skip to content

Commit 59001b0

Browse files
committed
feat(forms): add submission email verification
Signed-off-by: Robert <61327381+QuiteBitter@users.noreply.github.com>
1 parent c89d2d5 commit 59001b0

30 files changed

+1204
-7
lines changed

docs/API_v3.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,7 @@ Update a single or multiple properties of a question-object.
439439
- Restrictions:
440440
- It is **not allowed** to update one of the following key-value pairs: _id, formId, order_.
441441
- `extraSettings.confirmationRecipient` can only be enabled for short questions with `extraSettings.validationType` set to `email`.
442+
- `extraSettings.requireEmailVerification` can only be enabled for short questions with `extraSettings.validationType` set to `email` and `extraSettings.confirmationRecipient` set to `true`.
442443
- Response: **Status-Code OK**, as well as the id of the updated question.
443444

444445
```
@@ -904,6 +905,7 @@ Store Submission to Database
904905
- For Question-Types with pre-defined answers (`multiple`, `multiple_unique`, `dropdown`), the array contains the corresponding option-IDs.
905906
- For File-Uploads, the array contains the objects with key `uploadedFileId` (value from Upload a file endpoint).
906907
- To send a respondent confirmation email, set the corresponding short question to `validationType = email` and `confirmationRecipient = true`.
908+
- 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.
907909

908910
```
909911
{

docs/DataStructure.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ A submission-object describes a single submission by a user to a form.
148148
| formId | Integer | | The id of the form, the submission belongs to |
149149
| userId | String | | The nextcloud userId of the submitting user. If submission is anonymous, this contains `anon-user-<hash>` |
150150
| timestamp | unix timestamp | | When the user submitted |
151+
| isVerified | Boolean | | Whether the submission has completed email-address verification, if required |
151152
| answers | Array of [Answers](#answer) | | Array of the actual user answers, belonging to this submission.
152153
| userDisplayName | String | | Display name of the nextcloud-user, derived from `userId`. Contains `Anonymous user` if submitted anonymously. Not stored in DB.
153154

@@ -157,6 +158,7 @@ A submission-object describes a single submission by a user to a form.
157158
"formId": 3,
158159
"userId": "jonas",
159160
"timestamp": 1611274433,
161+
"isVerified": true,
160162
"answers": [],
161163
"userDisplayName": "jonas"
162164
}
@@ -241,6 +243,7 @@ Optional extra settings for some [Question Types](#question-types)
241243
| `validationType` | `short` | string | `null, 'phone', 'email', 'regex', 'number'` | Custom validation for checking a submission |
242244
| `validationRegex` | `short` | string | regular expression | if `validationType` is 'regex' this defines the regular expression to apply |
243245
| `confirmationRecipient` | `short` | Boolean | `true/false` | Marks an email question as recipient for respondent confirmation emails |
246+
| `requireEmailVerification` | `short` | Boolean | `true/false` | Requires respondents to verify the confirmation-recipient email before the submission is treated as verified |
244247
| `allowedFileTypes` | `file` | Array of strings | `'image', 'x-office/document'` | Allowed file types for file upload |
245248
| `allowedFileExtensions` | `file` | Array of strings | `'jpg', 'png'` | Allowed file extensions for file upload |
246249
| `maxAllowedFilesCount` | `file` | Integer | - | Maximum number of files that can be uploaded, 0 means no limit |

lib/AppInfo/Application.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use OCA\Forms\FormsMigrator;
1616
use OCA\Forms\Listener\AnalyticsDatasourceListener;
1717
use OCA\Forms\Listener\ConfirmationEmailListener;
18+
use OCA\Forms\Listener\SubmissionVerificationListener;
1819
use OCA\Forms\Listener\UserDeletedListener;
1920
use OCA\Forms\Middleware\ThrottleFormAccessMiddleware;
2021
use OCA\Forms\Search\SearchProvider;
@@ -45,6 +46,7 @@ public function register(IRegistrationContext $context): void {
4546

4647
$context->registerCapability(Capabilities::class);
4748
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
49+
$context->registerEventListener(FormSubmittedEvent::class, SubmissionVerificationListener::class);
4850
$context->registerEventListener(FormSubmittedEvent::class, ConfirmationEmailListener::class);
4951
$context->registerEventListener(DatasourceEvent::class, AnalyticsDatasourceListener::class);
5052
$context->registerMiddleware(ThrottleFormAccessMiddleware::class);

lib/Constants.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ class Constants {
150150
'validationType' => ['string'],
151151
'validationRegex' => ['string'],
152152
'confirmationRecipient' => ['boolean'],
153+
'requireEmailVerification' => ['boolean'],
153154
];
154155

155156
public const EXTRA_SETTINGS_FILE = [

lib/Controller/ApiController.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,9 @@ public function newQuestion(int $formId, ?string $type = null, ?string $subtype
550550
if (is_array($questionData['extraSettings'] ?? null)
551551
&& ($questionData['extraSettings']['confirmationRecipient'] ?? false) === true) {
552552
$questionData['extraSettings']['confirmationRecipient'] = false;
553+
if (($questionData['extraSettings']['requireEmailVerification'] ?? false) === true) {
554+
$questionData['extraSettings']['requireEmailVerification'] = false;
555+
}
553556
}
554557

555558
$newQuestion = Question::fromParams($questionData);
@@ -1371,6 +1374,7 @@ public function newSubmission(int $formId, array $answers, string $shareHash = '
13711374
$submission = new Submission();
13721375
$submission->setFormId($formId);
13731376
$submission->setTimestamp(time());
1377+
$submission->setIsVerified(true);
13741378

13751379
// If not logged in, anonymous, or embedded use anonID
13761380
if (!$this->currentUser || $form->getIsAnonymous()) {

lib/Controller/PageController.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use OCA\Forms\Db\SubmissionMapper;
1515
use OCA\Forms\Service\ConfigService;
1616
use OCA\Forms\Service\FormsService;
17+
use OCA\Forms\Service\SubmissionVerificationService;
1718

1819
use OCP\Accounts\IAccountManager;
1920
use OCP\AppFramework\Controller;
@@ -49,6 +50,7 @@ public function __construct(
4950
private SubmissionMapper $submissionMapper,
5051
private ConfigService $configService,
5152
private FormsService $formsService,
53+
private SubmissionVerificationService $submissionVerificationService,
5254
private IAccountManager $accountManager,
5355
private IInitialState $initialState,
5456
private IL10N $l10n,
@@ -118,6 +120,27 @@ public function submitViewWithSubmission(string $hash, int $submissionId): Templ
118120
return $this->formMapper->findByHash($hash)->getAllowEditSubmissions() ? $this->index($hash, $submissionId) : $this->index($hash);
119121
}
120122

123+
#[NoAdminRequired()]
124+
#[NoCSRFRequired()]
125+
#[PublicPage()]
126+
#[FrontpageRoute(verb: 'GET', url: '/verify/{token}', requirements: ['token' => '[a-f0-9]{48}'])]
127+
public function verifySubmissionEmail(string $token): PublicTemplateResponse {
128+
$isVerified = $this->submissionVerificationService->verifyToken($token);
129+
130+
$response = new PublicTemplateResponse($this->appName, 'verify', [
131+
'verified' => $isVerified,
132+
'headline' => $isVerified
133+
? $this->l10n->t('Email address verified')
134+
: $this->l10n->t('Email verification failed'),
135+
'message' => $isVerified
136+
? $this->l10n->t('Your email address has been verified successfully. You can close this page now.')
137+
: $this->l10n->t('The verification link is invalid or expired.'),
138+
]);
139+
$response->setHeaderTitle($this->l10n->t('Forms'));
140+
141+
return $response;
142+
}
143+
121144
/**
122145
* @param string $hash
123146
* @return RedirectResponse|TemplateResponse Redirect to login or internal view.

lib/Db/Submission.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,22 @@
1818
* @method void setUserId(string $value)
1919
* @method int getTimestamp()
2020
* @method void setTimestamp(integer $value)
21+
* @method bool getIsVerified()
22+
* @method void setIsVerified(bool $value)
2123
*/
2224
class Submission extends Entity {
2325
protected $formId;
2426
protected $userId;
2527
protected $timestamp;
28+
protected $isVerified;
2629

2730
/**
2831
* Submission constructor.
2932
*/
3033
public function __construct() {
3134
$this->addType('formId', 'integer');
3235
$this->addType('timestamp', 'integer');
36+
$this->addType('isVerified', 'boolean');
3337
}
3438

3539
/**
@@ -38,6 +42,7 @@ public function __construct() {
3842
* formId: int,
3943
* userId: string,
4044
* timestamp: int,
45+
* isVerified: bool,
4146
* }
4247
*/
4348
public function read(): array {
@@ -46,6 +51,7 @@ public function read(): array {
4651
'formId' => $this->getFormId(),
4752
'userId' => $this->getUserId(),
4853
'timestamp' => $this->getTimestamp(),
54+
'isVerified' => (bool)$this->getIsVerified(),
4955
];
5056
}
5157
}

lib/Db/SubmissionVerification.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Forms\Db;
11+
12+
use OCP\AppFramework\Db\Entity;
13+
14+
/**
15+
* @method int getSubmissionId()
16+
* @method void setSubmissionId(int $value)
17+
* @method string getRecipientEmailHash()
18+
* @method void setRecipientEmailHash(string $value)
19+
* @method string getTokenHash()
20+
* @method void setTokenHash(string $value)
21+
* @method int getExpires()
22+
* @method void setExpires(int $value)
23+
* @method int|null getUsed()
24+
* @method void setUsed(?int $value)
25+
*/
26+
class SubmissionVerification extends Entity {
27+
protected $submissionId;
28+
protected $recipientEmailHash;
29+
protected $tokenHash;
30+
protected $expires;
31+
protected $used;
32+
33+
public function __construct() {
34+
$this->addType('submissionId', 'integer');
35+
$this->addType('expires', 'integer');
36+
$this->addType('used', 'integer');
37+
}
38+
39+
/**
40+
* @return array{
41+
* id: int,
42+
* submissionId: int,
43+
* recipientEmailHash: string,
44+
* tokenHash: string,
45+
* expires: int,
46+
* used: int|null,
47+
* }
48+
*/
49+
public function read(): array {
50+
return [
51+
'id' => $this->getId(),
52+
'submissionId' => $this->getSubmissionId(),
53+
'recipientEmailHash' => $this->getRecipientEmailHash(),
54+
'tokenHash' => $this->getTokenHash(),
55+
'expires' => $this->getExpires(),
56+
'used' => $this->getUsed(),
57+
];
58+
}
59+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Forms\Db;
11+
12+
use OCP\AppFramework\Db\QBMapper;
13+
use OCP\DB\QueryBuilder\IQueryBuilder;
14+
use OCP\IDBConnection;
15+
16+
/**
17+
* @extends QBMapper<SubmissionVerification>
18+
*/
19+
class SubmissionVerificationMapper extends QBMapper {
20+
public function __construct(IDBConnection $db) {
21+
parent::__construct($db, 'forms_v2_submission_verify', SubmissionVerification::class);
22+
}
23+
24+
/**
25+
* @throws \OCP\AppFramework\Db\DoesNotExistException
26+
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
27+
*/
28+
public function findBySubmissionId(int $submissionId): SubmissionVerification {
29+
$qb = $this->db->getQueryBuilder();
30+
31+
$qb->select('*')
32+
->from($this->getTableName())
33+
->where(
34+
$qb->expr()->eq('submission_id', $qb->createNamedParameter($submissionId, IQueryBuilder::PARAM_INT))
35+
);
36+
37+
return $this->findEntity($qb);
38+
}
39+
40+
/**
41+
* @throws \OCP\AppFramework\Db\DoesNotExistException
42+
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
43+
*/
44+
public function findByTokenHash(string $tokenHash): SubmissionVerification {
45+
$qb = $this->db->getQueryBuilder();
46+
47+
$qb->select('*')
48+
->from($this->getTableName())
49+
->where(
50+
$qb->expr()->eq('token_hash', $qb->createNamedParameter($tokenHash, IQueryBuilder::PARAM_STR))
51+
);
52+
53+
return $this->findEntity($qb);
54+
}
55+
}

lib/Events/FormSubmittedEvent.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
class FormSubmittedEvent extends AbstractFormEvent {
1414
public const TRIGGER_CREATED = 'created';
1515
public const TRIGGER_UPDATED = 'updated';
16+
public const TRIGGER_VERIFIED = 'verified';
1617

1718
public function __construct(
1819
Form $form,

0 commit comments

Comments
 (0)