Skip to content

Commit ec01c84

Browse files
committed
feat: Add confirmation email for form respondents
Implements issue #525 - Send confirmation emails to form respondents after submission. Features: - Add database migration for confirmation email settings (enabled, subject, body) - Add confirmation email fields to Form entity - Implement email sending with placeholder replacement: - {formTitle}, {formDescription}, {data} placeholders - Field name placeholders (e.g. {name}, {email}) - Automatic data overview if {data} not in template - Add UI in Settings sidebar to configure confirmation emails - Automatically detect email field from form submissions - Send email from form owner's email address Technical changes: - Add sendConfirmationEmail() method to FormsService - Integrate email sending into notifyNewSubmission() flow - Add unit tests for confirmation email functionality - Update FormsService constructor with IMailer and AnswerMapper dependencies - Update documentation (DataStructure.md, CHANGELOG.en.md) The feature requires at least one email-validated short text question in the form. Email sending failures are logged but don't break the submission process. Signed-off-by: Dmitry Tretyakov <dtretyakov@gmail.com>
1 parent 9b027a2 commit ec01c84

File tree

14 files changed

+895
-9
lines changed

14 files changed

+895
-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/Db/Form.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@
5151
* @method void setLockedBy(string|null $value)
5252
* @method int getLockedUntil()
5353
* @method void setLockedUntil(int|null $value)
54+
* @method int getConfirmationEmailEnabled()
55+
* @method void setConfirmationEmailEnabled(bool $value)
56+
* @method string|null getConfirmationEmailSubject()
57+
* @method void setConfirmationEmailSubject(string|null $value)
58+
* @method string|null getConfirmationEmailBody()
59+
* @method void setConfirmationEmailBody(string|null $value)
5460
*/
5561
class Form extends Entity {
5662
protected $hash;
@@ -71,6 +77,9 @@ class Form extends Entity {
7177
protected $state;
7278
protected $lockedBy;
7379
protected $lockedUntil;
80+
protected $confirmationEmailEnabled;
81+
protected $confirmationEmailSubject;
82+
protected $confirmationEmailBody;
7483

7584
/**
7685
* Form constructor.
@@ -86,6 +95,7 @@ public function __construct() {
8695
$this->addType('state', 'integer');
8796
$this->addType('lockedBy', 'string');
8897
$this->addType('lockedUntil', 'integer');
98+
$this->addType('confirmationEmailEnabled', 'boolean');
8999
}
90100

91101
// JSON-Decoding of access-column.
@@ -159,6 +169,9 @@ public function setAccess(array $access): void {
159169
* state: 0|1|2,
160170
* lockedBy: ?string,
161171
* lockedUntil: ?int,
172+
* confirmationEmailEnabled: bool,
173+
* confirmationEmailSubject: ?string,
174+
* confirmationEmailBody: ?string,
162175
* }
163176
*/
164177
public function read() {
@@ -182,6 +195,9 @@ public function read() {
182195
'state' => $this->getState(),
183196
'lockedBy' => $this->getLockedBy(),
184197
'lockedUntil' => $this->getLockedUntil(),
198+
'confirmationEmailEnabled' => (bool)$this->getConfirmationEmailEnabled(),
199+
'confirmationEmailSubject' => $this->getConfirmationEmailSubject(),
200+
'confirmationEmailBody' => $this->getConfirmationEmailBody(),
185201
];
186202
}
187203
}
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 Version050202Date20251217203121 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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,9 @@
136136
* shares: list<FormsShare>,
137137
* submissionCount?: int,
138138
* submissionMessage: ?string,
139+
* confirmationEmailEnabled: bool,
140+
* confirmationEmailSubject: ?string,
141+
* confirmationEmailBody: ?string,
139142
* }
140143
*
141144
* @psalm-type FormsUploadedFile = array{

lib/Service/FormsService.php

Lines changed: 149 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
}
@@ -737,6 +741,151 @@ public function notifyNewSubmission(Form $form, Submission $submission): void {
737741
}
738742

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

742891
/**

openapi.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,10 @@
117117
"lockedBy",
118118
"lockedUntil",
119119
"shares",
120-
"submissionMessage"
120+
"submissionMessage",
121+
"confirmationEmailEnabled",
122+
"confirmationEmailSubject",
123+
"confirmationEmailBody"
121124
],
122125
"properties": {
123126
"id": {
@@ -222,6 +225,17 @@
222225
"submissionMessage": {
223226
"type": "string",
224227
"nullable": true
228+
},
229+
"confirmationEmailEnabled": {
230+
"type": "boolean"
231+
},
232+
"confirmationEmailSubject": {
233+
"type": "string",
234+
"nullable": true
235+
},
236+
"confirmationEmailBody": {
237+
"type": "string",
238+
"nullable": true
225239
}
226240
}
227241
},

0 commit comments

Comments
 (0)