Skip to content

Commit 9452b76

Browse files
committed
feat: add confirmation email for form respondents
Signed-off-by: Dmitry Tretyakov <dtretyakov@gmail.com>
1 parent f8567a5 commit 9452b76

File tree

16 files changed

+1714
-10
lines changed

16 files changed

+1714
-10
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: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@
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)
62+
* @method int|null getConfirmationEmailRecipient()
63+
* @method void setConfirmationEmailRecipient(int|null $value)
5664
*/
5765
class Form extends Entity {
5866
protected $hash;
@@ -74,6 +82,10 @@ class Form extends Entity {
7482
protected $lockedBy;
7583
protected $lockedUntil;
7684
protected $maxSubmissions;
85+
protected $confirmationEmailEnabled;
86+
protected $confirmationEmailSubject;
87+
protected $confirmationEmailBody;
88+
protected $confirmationEmailRecipient;
7789

7890
/**
7991
* Form constructor.
@@ -90,6 +102,8 @@ public function __construct() {
90102
$this->addType('lockedBy', 'string');
91103
$this->addType('lockedUntil', 'integer');
92104
$this->addType('maxSubmissions', 'integer');
105+
$this->addType('confirmationEmailEnabled', 'boolean');
106+
$this->addType('confirmationEmailRecipient', 'integer');
93107
}
94108

95109
// JSON-Decoding of access-column.
@@ -164,6 +178,10 @@ public function setAccess(array $access): void {
164178
* lockedBy: ?string,
165179
* lockedUntil: ?int,
166180
* maxSubmissions: ?int,
181+
* confirmationEmailEnabled: bool,
182+
* confirmationEmailSubject: ?string,
183+
* confirmationEmailBody: ?string,
184+
* confirmationEmailRecipient: ?int,
167185
* }
168186
*/
169187
public function read() {
@@ -188,6 +206,10 @@ public function read() {
188206
'lockedBy' => $this->getLockedBy(),
189207
'lockedUntil' => $this->getLockedUntil(),
190208
'maxSubmissions' => $this->getMaxSubmissions(),
209+
'confirmationEmailEnabled' => (bool)$this->getConfirmationEmailEnabled(),
210+
'confirmationEmailSubject' => $this->getConfirmationEmailSubject(),
211+
'confirmationEmailBody' => $this->getConfirmationEmailBody(),
212+
'confirmationEmailRecipient' => $this->getConfirmationEmailRecipient(),
191213
];
192214
}
193215
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
if (!$table->hasColumn('confirmation_email_recipient')) {
57+
$table->addColumn('confirmation_email_recipient', Types::INTEGER, [
58+
'notnull' => false,
59+
'default' => null,
60+
]);
61+
}
62+
63+
return $schema;
64+
}
65+
}

lib/ResponseDefinitions.php

Lines changed: 5 additions & 1 deletion
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,8 +142,11 @@
141142
* shares: list<FormsShare>,
142143
* submissionCount?: int,
143144
* submissionMessage: ?string,
145+
* confirmationEmailEnabled: bool,
146+
* confirmationEmailSubject: ?string,
147+
* confirmationEmailBody: ?string,
148+
* confirmationEmailRecipient: ?int,
144149
* }
145-
*
146150
* @psalm-type FormsUploadedFile = array{
147151
* uploadedFileId: int,
148152
* fileName: string

lib/Service/FormsService.php

Lines changed: 168 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,7 @@
3435
use OCP\IUser;
3536
use OCP\IUserManager;
3637
use OCP\IUserSession;
38+
use OCP\Mail\IMailer;
3739
use OCP\Search\ISearchQuery;
3840
use OCP\Security\ISecureRandom;
3941
use OCP\Share\IShare;
@@ -67,6 +69,8 @@ public function __construct(
6769
private IL10N $l10n,
6870
private LoggerInterface $logger,
6971
private IEventDispatcher $eventDispatcher,
72+
private IMailer $mailer,
73+
private AnswerMapper $answerMapper,
7074
) {
7175
$this->currentUser = $userSession->getUser();
7276
}
@@ -739,6 +743,170 @@ public function notifyNewSubmission(Form $form, Submission $submission): void {
739743
}
740744

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

744912
/**

0 commit comments

Comments
 (0)