Skip to content

Commit 61f3e69

Browse files
committed
Add guarded async confirmation emails
1 parent db97268 commit 61f3e69

14 files changed

Lines changed: 517 additions & 489 deletions
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\Forms\BackgroundJob;
10+
11+
use OCP\AppFramework\Utility\ITimeFactory;
12+
use OCP\BackgroundJob\QueuedJob;
13+
use OCP\Mail\IMailer;
14+
use Psr\Log\LoggerInterface;
15+
16+
class SendConfirmationMailJob extends QueuedJob {
17+
public function __construct(
18+
ITimeFactory $time,
19+
private IMailer $mailer,
20+
private LoggerInterface $logger,
21+
) {
22+
parent::__construct($time);
23+
}
24+
25+
/**
26+
* @param array{recipient: string, subject: string, body: string, formId: int, submissionId: int} $argument
27+
*/
28+
public function run($argument): void {
29+
$recipient = $argument['recipient'];
30+
$subject = $argument['subject'];
31+
$body = $argument['body'];
32+
$formId = $argument['formId'];
33+
$submissionId = $argument['submissionId'];
34+
35+
try {
36+
$message = $this->mailer->createMessage();
37+
$message->setSubject($subject);
38+
$message->setPlainBody($body);
39+
$message->setTo([$recipient]);
40+
$this->mailer->send($message);
41+
$this->logger->debug('Confirmation email sent successfully', [
42+
'formId' => $formId,
43+
'submissionId' => $submissionId,
44+
]);
45+
} catch (\Exception $e) {
46+
$this->logger->error('Error while sending confirmation email', [
47+
'exception' => $e,
48+
'formId' => $formId,
49+
'submissionId' => $submissionId,
50+
]);
51+
}
52+
}
53+
}

lib/Constants.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ class Constants {
1818
public const CONFIG_KEY_ALLOWSHOWTOALL = 'allowShowToAll';
1919
public const CONFIG_KEY_CREATIONALLOWEDGROUPS = 'creationAllowedGroups';
2020
public const CONFIG_KEY_RESTRICTCREATION = 'restrictCreation';
21+
public const CONFIG_KEY_ALLOWCONFIRMATIONEMAIL = 'allowConfirmationEmail';
2122
public const CONFIG_KEYS = [
2223
self::CONFIG_KEY_ALLOWPERMITALL,
2324
self::CONFIG_KEY_ALLOWPUBLICLINK,
2425
self::CONFIG_KEY_ALLOWSHOWTOALL,
2526
self::CONFIG_KEY_CREATIONALLOWEDGROUPS,
26-
self::CONFIG_KEY_RESTRICTCREATION
27+
self::CONFIG_KEY_RESTRICTCREATION,
28+
self::CONFIG_KEY_ALLOWCONFIRMATIONEMAIL,
2729
];
2830

2931
/**

lib/Controller/ApiController.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,14 @@
3131
use OCP\AppFramework\Db\DoesNotExistException;
3232
use OCP\AppFramework\Db\IMapperException;
3333
use OCP\AppFramework\Http;
34+
use OCP\AppFramework\Http\Attribute\AnonRateLimit;
3435
use OCP\AppFramework\Http\Attribute\ApiRoute;
3536
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
3637
use OCP\AppFramework\Http\Attribute\CORS;
3738
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
3839
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
3940
use OCP\AppFramework\Http\Attribute\PublicPage;
41+
use OCP\AppFramework\Http\Attribute\UserRateLimit;
4042
use OCP\AppFramework\Http\DataDownloadResponse;
4143
use OCP\AppFramework\Http\DataResponse;
4244
use OCP\AppFramework\OCS\OCSBadRequestException;
@@ -1353,6 +1355,8 @@ public function deleteAllSubmissions(int $formId): DataResponse {
13531355
*
13541356
* 201: empty response
13551357
*/
1358+
#[AnonRateLimit(limit: 3, period: 3600)]
1359+
#[UserRateLimit(limit: 10, period: 3600)]
13561360
#[CORS()]
13571361
#[NoAdminRequired()]
13581362
#[NoCSRFRequired()]

lib/Db/Form.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
* @method void setConfirmationEmailSubject(string|null $value)
6060
* @method string|null getConfirmationEmailBody()
6161
* @method void setConfirmationEmailBody(string|null $value)
62+
* @method int|null getConfirmationEmailRecipient()
63+
* @method void setConfirmationEmailRecipient(int|null $value)
6264
*/
6365
class Form extends Entity {
6466
protected $hash;
@@ -83,6 +85,7 @@ class Form extends Entity {
8385
protected $confirmationEmailEnabled;
8486
protected $confirmationEmailSubject;
8587
protected $confirmationEmailBody;
88+
protected $confirmationEmailRecipient;
8689

8790
/**
8891
* Form constructor.
@@ -100,6 +103,7 @@ public function __construct() {
100103
$this->addType('lockedUntil', 'integer');
101104
$this->addType('maxSubmissions', 'integer');
102105
$this->addType('confirmationEmailEnabled', 'boolean');
106+
$this->addType('confirmationEmailRecipient', 'integer');
103107
}
104108

105109
// JSON-Decoding of access-column.
@@ -177,6 +181,7 @@ public function setAccess(array $access): void {
177181
* confirmationEmailEnabled: bool,
178182
* confirmationEmailSubject: ?string,
179183
* confirmationEmailBody: ?string,
184+
* confirmationEmailRecipient: ?int,
180185
* }
181186
*/
182187
public function read() {
@@ -204,6 +209,7 @@ public function read() {
204209
'confirmationEmailEnabled' => (bool)$this->getConfirmationEmailEnabled(),
205210
'confirmationEmailSubject' => $this->getConfirmationEmailSubject(),
206211
'confirmationEmailBody' => $this->getConfirmationEmailBody(),
212+
'confirmationEmailRecipient' => $this->getConfirmationEmailRecipient(),
207213
];
208214
}
209215
}

lib/ResponseDefinitions.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@
145145
* confirmationEmailEnabled: bool,
146146
* confirmationEmailSubject: ?string,
147147
* confirmationEmailBody: ?string,
148+
* confirmationEmailRecipient: ?int,
148149
* }
149150
*
150151
* @psalm-type FormsUploadedFile = array{

lib/Service/ConfigService.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ public function getCreationAllowedGroups(): array {
4949
public function getRestrictCreation(): bool {
5050
return json_decode($this->config->getAppValue($this->appName, Constants::CONFIG_KEY_RESTRICTCREATION, 'false'));
5151
}
52+
public function getAllowConfirmationEmail(): bool {
53+
return json_decode($this->config->getAppValue($this->appName, Constants::CONFIG_KEY_ALLOWCONFIRMATIONEMAIL, 'false'));
54+
}
5255

5356
/**
5457
* Provide the full AppConfig
@@ -60,6 +63,7 @@ public function getAppConfig(): array {
6063
Constants::CONFIG_KEY_ALLOWSHOWTOALL => $this->getAllowShowToAll(),
6164
Constants::CONFIG_KEY_CREATIONALLOWEDGROUPS => $this->getCreationAllowedGroups(),
6265
Constants::CONFIG_KEY_RESTRICTCREATION => $this->getRestrictCreation(),
66+
Constants::CONFIG_KEY_ALLOWCONFIRMATIONEMAIL => $this->getAllowConfirmationEmail(),
6367

6468
// Additional, calculated information out of Config
6569
'canCreateForms' => $this->canCreateForms()

lib/Service/FormsService.php

Lines changed: 58 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
namespace OCA\Forms\Service;
99

1010
use OCA\Forms\Activity\ActivityManager;
11+
use OCA\Forms\BackgroundJob\SendConfirmationMailJob;
1112
use OCA\Forms\Constants;
1213
use OCA\Forms\Db\AnswerMapper;
1314
use OCA\Forms\Db\Form;
@@ -26,12 +27,15 @@
2627
use OCP\AppFramework\Db\IMapperException;
2728
use OCP\AppFramework\Http;
2829
use OCP\AppFramework\OCS\OCSForbiddenException;
30+
use OCP\BackgroundJob\IJobList;
2931
use OCP\EventDispatcher\IEventDispatcher;
3032
use OCP\Files\IRootFolder;
3133
use OCP\Files\NotFoundException;
34+
use OCP\ICacheFactory;
3235
use OCP\IGroup;
3336
use OCP\IGroupManager;
3437
use OCP\IL10N;
38+
use OCP\IMemcache;
3539
use OCP\IUser;
3640
use OCP\IUserManager;
3741
use OCP\IUserSession;
@@ -53,6 +57,9 @@
5357
class FormsService {
5458
private ?IUser $currentUser;
5559

60+
private const EMAIL_RATE_LIMIT = 3;
61+
private const EMAIL_RATE_LIMIT_TTL = 86400; // 24 hours
62+
5663
public function __construct(
5764
IUserSession $userSession,
5865
private ActivityManager $activityManager,
@@ -73,6 +80,8 @@ public function __construct(
7380
private IMailer $mailer,
7481
private IEmailValidator $emailValidator,
7582
private AnswerMapper $answerMapper,
83+
private IJobList $jobList,
84+
private ICacheFactory $cacheFactory,
7685
) {
7786
$this->currentUser = $userSession->getUser();
7887
}
@@ -762,18 +771,23 @@ private function sendConfirmationEmail(Form $form, Submission $submission): void
762771
return;
763772
}
764773

774+
if (!$this->configService->getAllowConfirmationEmail()) {
775+
$this->logger->debug('Confirmation email feature is disabled by administrator', [
776+
'formId' => $form->getId(),
777+
]);
778+
return;
779+
}
780+
765781
$subject = $form->getConfirmationEmailSubject();
766782
$body = $form->getConfirmationEmailBody();
767783

768-
// If no subject or body is set, use defaults
769784
if (empty($subject)) {
770785
$subject = $this->l10n->t('Thank you for your submission');
771786
}
772787
if (empty($body)) {
773788
$body = $this->l10n->t('Thank you for submitting the form "%s".', [$form->getTitle()]);
774789
}
775790

776-
// Get questions and answers
777791
$questions = $this->getQuestions($form->getId());
778792
$answers = $this->answerMapper->findBySubmission($submission->getId());
779793

@@ -805,61 +819,74 @@ private function sendConfirmationEmail(Form $form, Submission $submission): void
805819
return;
806820
}
807821

822+
$cacheKey = 'email_rl_' . hash('sha256', $form->getId() . ':' . strtolower($recipientEmail));
823+
$cache = $this->cacheFactory->createDistributed('forms_confirmation_email');
824+
if (!$cache instanceof IMemcache) {
825+
$this->logger->warning('Distributed cache does not support atomic increments for confirmation email rate limiting', [
826+
'formId' => $form->getId(),
827+
'submissionId' => $submission->getId(),
828+
]);
829+
return;
830+
}
831+
832+
if ($cache->add($cacheKey, 1, self::EMAIL_RATE_LIMIT_TTL)) {
833+
$count = 1;
834+
} else {
835+
$count = $cache->inc($cacheKey);
836+
if (!is_int($count)) {
837+
$this->logger->warning('Failed to increment confirmation email rate limit counter', [
838+
'formId' => $form->getId(),
839+
'submissionId' => $submission->getId(),
840+
]);
841+
return;
842+
}
843+
}
844+
845+
if ($count > self::EMAIL_RATE_LIMIT) {
846+
$this->logger->warning('Per-recipient confirmation email rate limit reached', [
847+
'formId' => $form->getId(),
848+
'submissionId' => $submission->getId(),
849+
]);
850+
return;
851+
}
852+
808853
// Replace placeholders in subject and body
809854
$replacements = [
810855
'{formTitle}' => $form->getTitle(),
811856
'{formDescription}' => $form->getDescription() ?? '',
812857
];
813858

814-
// Add field placeholders (e.g., {name}, {email})
815859
foreach ($questions as $question) {
816860
$questionId = $question['id'];
817861
$questionName = $question['name'] ?? '';
818862
$questionText = $question['text'] ?? '';
819863

820-
// Use question name if available, otherwise use text
821864
$fieldKey = !empty($questionName) ? $questionName : $questionText;
822-
// Sanitize field key for placeholder (remove special chars, lowercase)
823865
$fieldKey = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $fieldKey));
824866

825867
if (!empty($answerMap[$questionId])) {
826868
$answerValue = implode('; ', $answerMap[$questionId]);
827869
$replacements['{' . $fieldKey . '}'] = $answerValue;
828-
// Also support {questionName} format
829870
if (!empty($questionName)) {
830871
$replacements['{' . strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $questionName)) . '}'] = $answerValue;
831872
}
832873
}
833874
}
834875

835-
// Apply replacements
836876
$subject = str_replace(array_keys($replacements), array_values($replacements), $subject);
837877
$body = str_replace(array_keys($replacements), array_values($replacements), $body);
838878

839-
try {
840-
$message = $this->mailer->createMessage();
841-
$message->setSubject($subject);
842-
$message->setPlainBody($body);
843-
$message->setTo([$recipientEmail]);
844-
845-
$this->mailer->send($message);
846-
$this->logger->debug('Confirmation email sent successfully', [
847-
'formId' => $form->getId(),
848-
'submissionId' => $submission->getId(),
849-
'recipient' => $recipientEmail,
850-
]);
851-
} catch (\Exception $e) {
852-
// Handle exceptions silently, as this is not critical.
853-
// We don't want to break the submission process just because of an email error.
854-
$this->logger->error(
855-
'Error while sending confirmation email',
856-
[
857-
'exception' => $e,
858-
'formId' => $form->getId(),
859-
'submissionId' => $submission->getId(),
860-
]
861-
);
862-
}
879+
$this->jobList->add(SendConfirmationMailJob::class, [
880+
'recipient' => $recipientEmail,
881+
'subject' => $subject,
882+
'body' => $body,
883+
'formId' => $form->getId(),
884+
'submissionId' => $submission->getId(),
885+
]);
886+
$this->logger->debug('Confirmation email queued', [
887+
'formId' => $form->getId(),
888+
'submissionId' => $submission->getId(),
889+
]);
863890
}
864891

865892
/**

openapi.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,8 @@
122122
"submissionMessage",
123123
"confirmationEmailEnabled",
124124
"confirmationEmailSubject",
125-
"confirmationEmailBody"
125+
"confirmationEmailBody",
126+
"confirmationEmailRecipient"
126127
],
127128
"properties": {
128129
"id": {
@@ -246,6 +247,11 @@
246247
"confirmationEmailBody": {
247248
"type": "string",
248249
"nullable": true
250+
},
251+
"confirmationEmailRecipient": {
252+
"type": "integer",
253+
"format": "int64",
254+
"nullable": true
249255
}
250256
}
251257
},

src/FormsSettings.vue

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@
2424
label="displayName"
2525
@input="onCreationAllowedGroupsChange" />
2626
</NcSettingsSection>
27+
<NcSettingsSection
28+
:name="t('forms', 'Confirmation emails')"
29+
:description="
30+
t(
31+
'forms',
32+
'Allow form owners to send a confirmation email to respondents after submission.',
33+
)
34+
">
35+
<NcCheckboxRadioSwitch
36+
ref="switchAllowConfirmationEmail"
37+
v-model="appConfig.allowConfirmationEmail"
38+
type="switch"
39+
@update:modelValue="onAllowConfirmationEmailChange">
40+
{{ t('forms', 'Allow confirmation emails to form respondents') }}
41+
</NcCheckboxRadioSwitch>
42+
</NcSettingsSection>
2743
<NcSettingsSection :name="t('forms', 'Form sharing')">
2844
<NcCheckboxRadioSwitch
2945
ref="switchAllowPublicLink"
@@ -129,6 +145,13 @@ export default {
129145
el.loading = false
130146
},
131147
148+
async onAllowConfirmationEmailChange(newVal) {
149+
const el = this.$refs.switchAllowConfirmationEmail
150+
el.loading = true
151+
await this.saveAppConfig('allowConfirmationEmail', newVal)
152+
el.loading = false
153+
},
154+
132155
/**
133156
* Save a key-value pair to the appConfig.
134157
*

0 commit comments

Comments
 (0)