Skip to content

Commit cdbd2d0

Browse files
committed
feat: Add confirmation email for form respondents
Implements issue #525 - Send confirmation emails to form respondents after submission. Features: - Add confirmation email settings (enabled, recipient field, subject, body) to Form entity. - Implement email sending with placeholder replacement: - {formTitle}, {formDescription} - Field placeholders based on name or label (e.g. {name}, {email}). - Allow form creators to explicitly select the recipient email field when multiple email fields are present. - Add UI in Settings sidebar to configure confirmation emails and recipient selection. - Replace basic email validation with Nextcloud internal IEmailValidator. - Integration with activity notifications and background jobs for file syncing. Technical changes: - Database migration for new Form properties. - Enhanced FormsService with email sending logic and validation. - Extensive unit and integration tests covering the new functionality. - Updated API documentation and OpenAPI spec. Signed-off-by: Dmitry Tretyakov <dtretyakov@gmail.com>
1 parent f8567a5 commit cdbd2d0

File tree

16 files changed

+1474
-9
lines changed

16 files changed

+1474
-9
lines changed

CHANGELOG.en.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@
55

66
# Changelog
77

8+
## Unreleased
9+
10+
- **Confirmation emails for respondents**
11+
12+
Form owners can enable an automatic confirmation email that is sent to the respondent after a successful submission.
13+
Requires an email-validated short text question in the form.
14+
15+
Supported placeholders in subject/body:
16+
17+
- `{formTitle}`, `{formDescription}`
18+
- `{<fieldName>}` (question `name` or text, sanitized)
19+
820
## v5.2.0 - 2025-09-25
921

1022
- **Time: restrictions and ranges**

docs/API_v3.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ Returns the full-depth object of the requested form (without submissions).
175175
"state": 0,
176176
"lockedBy": null,
177177
"lockedUntil": null,
178+
"confirmationEmailEnabled": false,
179+
"confirmationEmailSubject": null,
180+
"confirmationEmailBody": null,
178181
"permissions": [
179182
"edit",
180183
"results",

docs/DataStructure.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ This document describes the Object-Structure, that is used within the Forms App
2121
| description | String | max. 8192 ch. | The Form description |
2222
| ownerId | String | | The nextcloud userId of the form owner |
2323
| 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) |
24+
| confirmationEmailEnabled | Boolean | | If enabled, send a confirmation email to the respondent after submission |
25+
| confirmationEmailSubject | String | max. 255 ch. | Optional confirmation email subject template (supports placeholders) |
26+
| confirmationEmailBody | String | | Optional confirmation email body template (plain text, supports placeholders) |
2427
| created | unix timestamp | | When the form has been created |
2528
| access | [Access-Object](#access-object) | | Describing access-settings of the form |
2629
| expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ |
@@ -46,6 +49,9 @@ This document describes the Object-Structure, that is used within the Forms App
4649
"title": "Form 1",
4750
"description": "Description Text",
4851
"ownerId": "jonas",
52+
"confirmationEmailEnabled": false,
53+
"confirmationEmailSubject": null,
54+
"confirmationEmailBody": null,
4955
"created": 1611240961,
5056
"access": {},
5157
"expires": 0,

lib/Constants.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ class Constants {
147147
];
148148

149149
public const EXTRA_SETTINGS_SHORT = [
150+
'confirmationEmailRecipient' => ['boolean'],
150151
'validationType' => ['string'],
151152
'validationRegex' => ['string'],
152153
];

lib/Db/Form.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@
5353
* @method int|null getMaxSubmissions()
5454
* @method void setMaxSubmissions(int|null $value)
5555
* @method void setLockedUntil(int|null $value)
56+
* @method int getConfirmationEmailEnabled()
57+
* @method void setConfirmationEmailEnabled(bool $value)
58+
* @method string|null getConfirmationEmailSubject()
59+
* @method void setConfirmationEmailSubject(string|null $value)
60+
* @method string|null getConfirmationEmailBody()
61+
* @method void setConfirmationEmailBody(string|null $value)
5662
*/
5763
class Form extends Entity {
5864
protected $hash;
@@ -74,6 +80,9 @@ class Form extends Entity {
7480
protected $lockedBy;
7581
protected $lockedUntil;
7682
protected $maxSubmissions;
83+
protected $confirmationEmailEnabled;
84+
protected $confirmationEmailSubject;
85+
protected $confirmationEmailBody;
7786

7887
/**
7988
* Form constructor.
@@ -90,6 +99,7 @@ public function __construct() {
9099
$this->addType('lockedBy', 'string');
91100
$this->addType('lockedUntil', 'integer');
92101
$this->addType('maxSubmissions', 'integer');
102+
$this->addType('confirmationEmailEnabled', 'boolean');
93103
}
94104

95105
// JSON-Decoding of access-column.
@@ -164,6 +174,9 @@ public function setAccess(array $access): void {
164174
* lockedBy: ?string,
165175
* lockedUntil: ?int,
166176
* maxSubmissions: ?int,
177+
* confirmationEmailEnabled: bool,
178+
* confirmationEmailSubject: ?string,
179+
* confirmationEmailBody: ?string,
167180
* }
168181
*/
169182
public function read() {
@@ -188,6 +201,9 @@ public function read() {
188201
'lockedBy' => $this->getLockedBy(),
189202
'lockedUntil' => $this->getLockedUntil(),
190203
'maxSubmissions' => $this->getMaxSubmissions(),
204+
'confirmationEmailEnabled' => (bool)$this->getConfirmationEmailEnabled(),
205+
'confirmationEmailSubject' => $this->getConfirmationEmailSubject(),
206+
'confirmationEmailBody' => $this->getConfirmationEmailBody(),
191207
];
192208
}
193209
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Forms\Migration;
11+
12+
use Closure;
13+
use OCP\DB\ISchemaWrapper;
14+
use OCP\DB\Types;
15+
use OCP\Migration\IOutput;
16+
use OCP\Migration\SimpleMigrationStep;
17+
18+
/**
19+
* Add confirmation email fields to forms
20+
*/
21+
class Version050301Date20260413233000 extends SimpleMigrationStep {
22+
23+
/**
24+
* @param IOutput $output
25+
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
26+
* @param array $options
27+
* @return null|ISchemaWrapper
28+
*/
29+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
30+
/** @var ISchemaWrapper $schema */
31+
$schema = $schemaClosure();
32+
$table = $schema->getTable('forms_v2_forms');
33+
34+
if (!$table->hasColumn('confirmation_email_enabled')) {
35+
$table->addColumn('confirmation_email_enabled', Types::BOOLEAN, [
36+
'notnull' => false,
37+
'default' => 0,
38+
]);
39+
}
40+
41+
if (!$table->hasColumn('confirmation_email_subject')) {
42+
$table->addColumn('confirmation_email_subject', Types::STRING, [
43+
'notnull' => false,
44+
'default' => null,
45+
'length' => 255,
46+
]);
47+
}
48+
49+
if (!$table->hasColumn('confirmation_email_body')) {
50+
$table->addColumn('confirmation_email_body', Types::TEXT, [
51+
'notnull' => false,
52+
'default' => null,
53+
]);
54+
}
55+
56+
return $schema;
57+
}
58+
}

lib/ResponseDefinitions.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
* dateMax?: int,
2727
* dateMin?: int,
2828
* dateRange?: bool,
29+
* confirmationEmailRecipient?: bool,
2930
* maxAllowedFilesCount?: int,
3031
* maxFileSize?: int,
3132
* optionsHighest?: 2|3|4|5|6|7|8|9|10,
@@ -141,6 +142,9 @@
141142
* shares: list<FormsShare>,
142143
* submissionCount?: int,
143144
* submissionMessage: ?string,
145+
* confirmationEmailEnabled: bool,
146+
* confirmationEmailSubject: ?string,
147+
* confirmationEmailBody: ?string,
144148
* }
145149
*
146150
* @psalm-type FormsUploadedFile = array{

lib/Service/FormsService.php

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
use OCA\Forms\Activity\ActivityManager;
1111
use OCA\Forms\Constants;
12+
use OCA\Forms\Db\AnswerMapper;
1213
use OCA\Forms\Db\Form;
1314
use OCA\Forms\Db\FormMapper;
1415
use OCA\Forms\Db\OptionMapper;
@@ -34,6 +35,8 @@
3435
use OCP\IUser;
3536
use OCP\IUserManager;
3637
use OCP\IUserSession;
38+
use OCP\Mail\IEmailValidator;
39+
use OCP\Mail\IMailer;
3740
use OCP\Search\ISearchQuery;
3841
use OCP\Security\ISecureRandom;
3942
use OCP\Share\IShare;
@@ -67,6 +70,9 @@ public function __construct(
6770
private IL10N $l10n,
6871
private LoggerInterface $logger,
6972
private IEventDispatcher $eventDispatcher,
73+
private IMailer $mailer,
74+
private IEmailValidator $emailValidator,
75+
private AnswerMapper $answerMapper,
7076
) {
7177
$this->currentUser = $userSession->getUser();
7278
}
@@ -739,6 +745,170 @@ public function notifyNewSubmission(Form $form, Submission $submission): void {
739745
}
740746

741747
$this->eventDispatcher->dispatchTyped(new FormSubmittedEvent($form, $submission));
748+
749+
// Send confirmation email if enabled
750+
$this->sendConfirmationEmail($form, $submission);
751+
}
752+
753+
/**
754+
* Send confirmation email to the respondent
755+
*
756+
* @param Form $form The form that was submitted
757+
* @param Submission $submission The submission
758+
*/
759+
private function sendConfirmationEmail(Form $form, Submission $submission): void {
760+
// Check if confirmation email is enabled
761+
if (!$form->getConfirmationEmailEnabled()) {
762+
return;
763+
}
764+
765+
$subject = $form->getConfirmationEmailSubject();
766+
$body = $form->getConfirmationEmailBody();
767+
768+
// If no subject or body is set, use defaults
769+
if (empty($subject)) {
770+
$subject = $this->l10n->t('Thank you for your submission');
771+
}
772+
if (empty($body)) {
773+
$body = $this->l10n->t('Thank you for submitting the form "%s".', [$form->getTitle()]);
774+
}
775+
776+
// Get questions and answers
777+
$questions = $this->getQuestions($form->getId());
778+
$answers = $this->answerMapper->findBySubmission($submission->getId());
779+
780+
$answerMap = [];
781+
foreach ($answers as $answer) {
782+
$questionId = $answer->getQuestionId();
783+
if (!isset($answerMap[$questionId])) {
784+
$answerMap[$questionId] = [];
785+
}
786+
$answerMap[$questionId][] = $answer->getText();
787+
}
788+
789+
$recipientQuestion = $this->getConfirmationEmailRecipientQuestion($questions);
790+
if ($recipientQuestion === null) {
791+
$this->logger->debug('No confirmation email recipient question is available', [
792+
'formId' => $form->getId(),
793+
'submissionId' => $submission->getId(),
794+
]);
795+
return;
796+
}
797+
798+
$recipientQuestionId = $recipientQuestion['id'];
799+
$recipientEmail = $answerMap[$recipientQuestionId][0] ?? null;
800+
if ($recipientEmail === null || !$this->emailValidator->isValid($recipientEmail)) {
801+
$this->logger->debug('No valid email address found in submission for confirmation email', [
802+
'formId' => $form->getId(),
803+
'submissionId' => $submission->getId(),
804+
]);
805+
return;
806+
}
807+
808+
// Replace placeholders in subject and body
809+
$replacements = [
810+
'{formTitle}' => $form->getTitle(),
811+
'{formDescription}' => $form->getDescription() ?? '',
812+
];
813+
814+
// Add field placeholders (e.g., {name}, {email})
815+
foreach ($questions as $question) {
816+
$questionId = $question['id'];
817+
$questionName = $question['name'] ?? '';
818+
$questionText = $question['text'] ?? '';
819+
820+
// Use question name if available, otherwise use text
821+
$fieldKey = !empty($questionName) ? $questionName : $questionText;
822+
// Sanitize field key for placeholder (remove special chars, lowercase)
823+
$fieldKey = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $fieldKey));
824+
825+
if (!empty($answerMap[$questionId])) {
826+
$answerValue = implode('; ', $answerMap[$questionId]);
827+
$replacements['{' . $fieldKey . '}'] = $answerValue;
828+
// Also support {questionName} format
829+
if (!empty($questionName)) {
830+
$replacements['{' . strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $questionName)) . '}'] = $answerValue;
831+
}
832+
}
833+
}
834+
835+
// Apply replacements
836+
$subject = str_replace(array_keys($replacements), array_values($replacements), $subject);
837+
$body = str_replace(array_keys($replacements), array_values($replacements), $body);
838+
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+
}
863+
}
864+
865+
/**
866+
* @param list<FormsQuestion> $questions
867+
* @return FormsQuestion|null
868+
*/
869+
private function getConfirmationEmailRecipientQuestion(array $questions): ?array {
870+
$emailQuestions = array_values(array_filter(
871+
$questions,
872+
fn (array $question): bool => $this->isConfirmationEmailQuestion($question),
873+
));
874+
875+
if ($emailQuestions === []) {
876+
return null;
877+
}
878+
879+
$explicitRecipients = array_values(array_filter(
880+
$emailQuestions,
881+
function (array $question): bool {
882+
$extraSettings = (array)($question['extraSettings'] ?? []);
883+
return !empty($extraSettings['confirmationEmailRecipient']);
884+
},
885+
));
886+
887+
if (count($explicitRecipients) === 1) {
888+
return $explicitRecipients[0];
889+
}
890+
891+
if (count($explicitRecipients) > 1) {
892+
return null;
893+
}
894+
895+
if (count($emailQuestions) === 1) {
896+
return $emailQuestions[0];
897+
}
898+
899+
return null;
900+
}
901+
902+
/**
903+
* @param FormsQuestion $question
904+
*/
905+
private function isConfirmationEmailQuestion(array $question): bool {
906+
if (($question['type'] ?? null) !== Constants::ANSWER_TYPE_SHORT) {
907+
return false;
908+
}
909+
910+
$extraSettings = (array)($question['extraSettings'] ?? []);
911+
return ($extraSettings['validationType'] ?? null) === 'email';
742912
}
743913

744914
/**

0 commit comments

Comments
 (0)