From e3c59dc369c6d5e8eec7c8c342433e64e551327b Mon Sep 17 00:00:00 2001 From: Dmitry Tretyakov Date: Mon, 13 Apr 2026 23:55:20 +0200 Subject: [PATCH 1/2] feat: Add confirmation email for form respondents Implements issue #525 - Send confirmation emails to form respondents after submission. Features: - Add confirmation email settings (enabled, 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's 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 --- CHANGELOG.en.md | 12 + docs/API_v3.md | 3 + docs/DataStructure.md | 6 + lib/Constants.php | 1 + lib/Db/Form.php | 22 + .../Version050301Date20260413233000.php | 65 ++ lib/ResponseDefinitions.php | 8 +- lib/Service/FormsService.php | 170 +++ openapi.json | 22 +- src/components/Questions/QuestionShort.vue | 25 + .../SidebarTabs/SettingsSidebarTab.vue | 184 ++++ tests/Integration/Api/ApiV3Test.php | 8 + .../Api/RespectAdminSettingsTest.php | 4 + tests/Unit/Controller/ApiControllerTest.php | 9 +- tests/Unit/FormsMigratorTest.php | 4 + tests/Unit/Service/FormsServiceTest.php | 970 +++++++++++++++++- 16 files changed, 1502 insertions(+), 11 deletions(-) create mode 100644 lib/Migration/Version050301Date20260413233000.php diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index 55002e372..47c7ea782 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -5,6 +5,18 @@ # Changelog +## Unreleased + +- **Confirmation emails for respondents** + + Form owners can enable an automatic confirmation email that is sent to the respondent after a successful submission. + Requires an email-validated short text question in the form. + + Supported placeholders in subject/body: + + - `{formTitle}`, `{formDescription}` + - `{}` (question `name` or text, sanitized) + ## v5.2.0 - 2025-09-25 - **Time: restrictions and ranges** diff --git a/docs/API_v3.md b/docs/API_v3.md index 40c61d54c..6fc7af347 100644 --- a/docs/API_v3.md +++ b/docs/API_v3.md @@ -175,6 +175,9 @@ Returns the full-depth object of the requested form (without submissions). "state": 0, "lockedBy": null, "lockedUntil": null, + "confirmationEmailEnabled": false, + "confirmationEmailSubject": null, + "confirmationEmailBody": null, "permissions": [ "edit", "results", diff --git a/docs/DataStructure.md b/docs/DataStructure.md index a4dc03c64..de7301c5f 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -21,6 +21,9 @@ This document describes the Object-Structure, that is used within the Forms App | description | String | max. 8192 ch. | The Form description | | ownerId | String | | The nextcloud userId of the form owner | | 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) | +| confirmationEmailEnabled | Boolean | | If enabled, send a confirmation email to the respondent after submission | +| confirmationEmailSubject | String | max. 255 ch. | Optional confirmation email subject template (supports placeholders) | +| confirmationEmailBody | String | | Optional confirmation email body template (plain text, supports placeholders) | | created | unix timestamp | | When the form has been created | | access | [Access-Object](#access-object) | | Describing access-settings of the form | | 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 "title": "Form 1", "description": "Description Text", "ownerId": "jonas", + "confirmationEmailEnabled": false, + "confirmationEmailSubject": null, + "confirmationEmailBody": null, "created": 1611240961, "access": {}, "expires": 0, diff --git a/lib/Constants.php b/lib/Constants.php index 0ff0341bd..84e8ef640 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -147,6 +147,7 @@ class Constants { ]; public const EXTRA_SETTINGS_SHORT = [ + 'confirmationEmailRecipient' => ['boolean'], 'validationType' => ['string'], 'validationRegex' => ['string'], ]; diff --git a/lib/Db/Form.php b/lib/Db/Form.php index d3c9999df..0d845e061 100644 --- a/lib/Db/Form.php +++ b/lib/Db/Form.php @@ -53,6 +53,14 @@ * @method int|null getMaxSubmissions() * @method void setMaxSubmissions(int|null $value) * @method void setLockedUntil(int|null $value) + * @method int getConfirmationEmailEnabled() + * @method void setConfirmationEmailEnabled(bool $value) + * @method string|null getConfirmationEmailSubject() + * @method void setConfirmationEmailSubject(string|null $value) + * @method string|null getConfirmationEmailBody() + * @method void setConfirmationEmailBody(string|null $value) + * @method int|null getConfirmationEmailRecipient() + * @method void setConfirmationEmailRecipient(int|null $value) */ class Form extends Entity { protected $hash; @@ -74,6 +82,10 @@ class Form extends Entity { protected $lockedBy; protected $lockedUntil; protected $maxSubmissions; + protected $confirmationEmailEnabled; + protected $confirmationEmailSubject; + protected $confirmationEmailBody; + protected $confirmationEmailRecipient; /** * Form constructor. @@ -90,6 +102,8 @@ public function __construct() { $this->addType('lockedBy', 'string'); $this->addType('lockedUntil', 'integer'); $this->addType('maxSubmissions', 'integer'); + $this->addType('confirmationEmailEnabled', 'boolean'); + $this->addType('confirmationEmailRecipient', 'integer'); } // JSON-Decoding of access-column. @@ -164,6 +178,10 @@ public function setAccess(array $access): void { * lockedBy: ?string, * lockedUntil: ?int, * maxSubmissions: ?int, + * confirmationEmailEnabled: bool, + * confirmationEmailSubject: ?string, + * confirmationEmailBody: ?string, + * confirmationEmailRecipient: ?int, * } */ public function read() { @@ -188,6 +206,10 @@ public function read() { 'lockedBy' => $this->getLockedBy(), 'lockedUntil' => $this->getLockedUntil(), 'maxSubmissions' => $this->getMaxSubmissions(), + 'confirmationEmailEnabled' => (bool)$this->getConfirmationEmailEnabled(), + 'confirmationEmailSubject' => $this->getConfirmationEmailSubject(), + 'confirmationEmailBody' => $this->getConfirmationEmailBody(), + 'confirmationEmailRecipient' => $this->getConfirmationEmailRecipient(), ]; } } diff --git a/lib/Migration/Version050301Date20260413233000.php b/lib/Migration/Version050301Date20260413233000.php new file mode 100644 index 000000000..ad03a8f6c --- /dev/null +++ b/lib/Migration/Version050301Date20260413233000.php @@ -0,0 +1,65 @@ +getTable('forms_v2_forms'); + + if (!$table->hasColumn('confirmation_email_enabled')) { + $table->addColumn('confirmation_email_enabled', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => 0, + ]); + } + + if (!$table->hasColumn('confirmation_email_subject')) { + $table->addColumn('confirmation_email_subject', Types::STRING, [ + 'notnull' => false, + 'default' => null, + 'length' => 255, + ]); + } + + if (!$table->hasColumn('confirmation_email_body')) { + $table->addColumn('confirmation_email_body', Types::TEXT, [ + 'notnull' => false, + 'default' => null, + ]); + } + + if (!$table->hasColumn('confirmation_email_recipient')) { + $table->addColumn('confirmation_email_recipient', Types::INTEGER, [ + 'notnull' => false, + 'default' => null, + ]); + } + + return $schema; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 27fea25bb..7de1aa928 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -26,6 +26,7 @@ * dateMax?: int, * dateMin?: int, * dateRange?: bool, + * confirmationEmailRecipient?: bool, * maxAllowedFilesCount?: int, * maxFileSize?: int, * optionsHighest?: 2|3|4|5|6|7|8|9|10, @@ -141,8 +142,11 @@ * shares: list, * submissionCount?: int, * submissionMessage: ?string, - * } - * + * confirmationEmailEnabled: bool, + * confirmationEmailSubject: ?string, + * confirmationEmailBody: ?string, + * confirmationEmailRecipient: ?int, + * } * * @psalm-type FormsUploadedFile = array{ * uploadedFileId: int, * fileName: string diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index f5b421c3a..e78aa281e 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -9,6 +9,7 @@ use OCA\Forms\Activity\ActivityManager; use OCA\Forms\Constants; +use OCA\Forms\Db\AnswerMapper; use OCA\Forms\Db\Form; use OCA\Forms\Db\FormMapper; use OCA\Forms\Db\OptionMapper; @@ -34,6 +35,8 @@ use OCP\IUser; use OCP\IUserManager; use OCP\IUserSession; +use OCP\Mail\IEmailValidator; +use OCP\Mail\IMailer; use OCP\Search\ISearchQuery; use OCP\Security\ISecureRandom; use OCP\Share\IShare; @@ -67,6 +70,9 @@ public function __construct( private IL10N $l10n, private LoggerInterface $logger, private IEventDispatcher $eventDispatcher, + private IMailer $mailer, + private IEmailValidator $emailValidator, + private AnswerMapper $answerMapper, ) { $this->currentUser = $userSession->getUser(); } @@ -739,6 +745,170 @@ public function notifyNewSubmission(Form $form, Submission $submission): void { } $this->eventDispatcher->dispatchTyped(new FormSubmittedEvent($form, $submission)); + + // Send confirmation email if enabled + $this->sendConfirmationEmail($form, $submission); + } + + /** + * Send confirmation email to the respondent + * + * @param Form $form The form that was submitted + * @param Submission $submission The submission + */ + private function sendConfirmationEmail(Form $form, Submission $submission): void { + // Check if confirmation email is enabled + if (!$form->getConfirmationEmailEnabled()) { + return; + } + + $subject = $form->getConfirmationEmailSubject(); + $body = $form->getConfirmationEmailBody(); + + // If no subject or body is set, use defaults + if (empty($subject)) { + $subject = $this->l10n->t('Thank you for your submission'); + } + if (empty($body)) { + $body = $this->l10n->t('Thank you for submitting the form "%s".', [$form->getTitle()]); + } + + // Get questions and answers + $questions = $this->getQuestions($form->getId()); + $answers = $this->answerMapper->findBySubmission($submission->getId()); + + $answerMap = []; + foreach ($answers as $answer) { + $questionId = $answer->getQuestionId(); + if (!isset($answerMap[$questionId])) { + $answerMap[$questionId] = []; + } + $answerMap[$questionId][] = $answer->getText(); + } + + $recipientQuestion = $this->getConfirmationEmailRecipientQuestion($questions); + if ($recipientQuestion === null) { + $this->logger->debug('No confirmation email recipient question is available', [ + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + ]); + return; + } + + $recipientQuestionId = $recipientQuestion['id']; + $recipientEmail = $answerMap[$recipientQuestionId][0] ?? null; + if ($recipientEmail === null || !$this->emailValidator->isValid($recipientEmail)) { + $this->logger->debug('No valid email address found in submission for confirmation email', [ + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + ]); + return; + } + + // Replace placeholders in subject and body + $replacements = [ + '{formTitle}' => $form->getTitle(), + '{formDescription}' => $form->getDescription() ?? '', + ]; + + // Add field placeholders (e.g., {name}, {email}) + foreach ($questions as $question) { + $questionId = $question['id']; + $questionName = $question['name'] ?? ''; + $questionText = $question['text'] ?? ''; + + // Use question name if available, otherwise use text + $fieldKey = !empty($questionName) ? $questionName : $questionText; + // Sanitize field key for placeholder (remove special chars, lowercase) + $fieldKey = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $fieldKey)); + + if (!empty($answerMap[$questionId])) { + $answerValue = implode('; ', $answerMap[$questionId]); + $replacements['{' . $fieldKey . '}'] = $answerValue; + // Also support {questionName} format + if (!empty($questionName)) { + $replacements['{' . strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $questionName)) . '}'] = $answerValue; + } + } + } + + // Apply replacements + $subject = str_replace(array_keys($replacements), array_values($replacements), $subject); + $body = str_replace(array_keys($replacements), array_values($replacements), $body); + + try { + $message = $this->mailer->createMessage(); + $message->setSubject($subject); + $message->setPlainBody($body); + $message->setTo([$recipientEmail]); + + $this->mailer->send($message); + $this->logger->debug('Confirmation email sent successfully', [ + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + 'recipient' => $recipientEmail, + ]); + } catch (\Exception $e) { + // Handle exceptions silently, as this is not critical. + // We don't want to break the submission process just because of an email error. + $this->logger->error( + 'Error while sending confirmation email', + [ + 'exception' => $e, + 'formId' => $form->getId(), + 'submissionId' => $submission->getId(), + ] + ); + } + } + + /** + * @param list $questions + * @return FormsQuestion|null + */ + private function getConfirmationEmailRecipientQuestion(array $questions): ?array { + $emailQuestions = array_values(array_filter( + $questions, + fn (array $question): bool => $this->isConfirmationEmailQuestion($question), + )); + + if ($emailQuestions === []) { + return null; + } + + $explicitRecipients = array_values(array_filter( + $emailQuestions, + function (array $question): bool { + $extraSettings = (array)($question['extraSettings'] ?? []); + return !empty($extraSettings['confirmationEmailRecipient']); + }, + )); + + if (count($explicitRecipients) === 1) { + return $explicitRecipients[0]; + } + + if (count($explicitRecipients) > 1) { + return null; + } + + if (count($emailQuestions) === 1) { + return $emailQuestions[0]; + } + + return null; + } + + /** + * @param FormsQuestion $question + */ + private function isConfirmationEmailQuestion(array $question): bool { + if (($question['type'] ?? null) !== Constants::ANSWER_TYPE_SHORT) { + return false; + } + + $extraSettings = (array)($question['extraSettings'] ?? []); + return ($extraSettings['validationType'] ?? null) === 'email'; } /** diff --git a/openapi.json b/openapi.json index 9d80a1151..6c53ea70d 100644 --- a/openapi.json +++ b/openapi.json @@ -119,7 +119,11 @@ "lockedUntil", "maxSubmissions", "shares", - "submissionMessage" + "submissionMessage", + "confirmationEmailEnabled", + "confirmationEmailSubject", + "confirmationEmailBody", + "confirmationEmailRecipient" ], "properties": { "id": { @@ -232,6 +236,22 @@ "submissionMessage": { "type": "string", "nullable": true + }, + "confirmationEmailEnabled": { + "type": "boolean" + }, + "confirmationEmailSubject": { + "type": "string", + "nullable": true + }, + "confirmationEmailBody": { + "type": "string", + "nullable": true + }, + "confirmationEmailRecipient": { + "type": "integer", + "format": "int64", + "nullable": true } } }, diff --git a/src/components/Questions/QuestionShort.vue b/src/components/Questions/QuestionShort.vue index b14514440..451549156 100644 --- a/src/components/Questions/QuestionShort.vue +++ b/src/components/Questions/QuestionShort.vue @@ -66,12 +66,19 @@ /^[a-z]{3}$/i + + {{ t('forms', 'Use for confirmation emails') }} +