From 9452b76f97a5430bd0b94407e9ce90e44b4c67a6 Mon Sep 17 00:00:00 2001 From: Dmitry Tretyakov Date: Tue, 14 Apr 2026 09:27:37 +0200 Subject: [PATCH] feat: add confirmation email for form respondents 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 | 6 +- lib/Service/FormsService.php | 168 ++++ openapi.json | 25 +- src/components/Questions/QuestionShort.vue | 2 + .../SidebarTabs/SettingsSidebarTab.vue | 439 ++++++++ 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 | 950 +++++++++++++++++- 16 files changed, 1714 insertions(+), 10 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..6aa169fc7 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..074ac2be6 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,7 @@ use OCP\IUser; use OCP\IUserManager; use OCP\IUserSession; +use OCP\Mail\IMailer; use OCP\Search\ISearchQuery; use OCP\Security\ISecureRandom; use OCP\Share\IShare; @@ -67,6 +69,8 @@ public function __construct( private IL10N $l10n, private LoggerInterface $logger, private IEventDispatcher $eventDispatcher, + private IMailer $mailer, + private AnswerMapper $answerMapper, ) { $this->currentUser = $userSession->getUser(); } @@ -739,6 +743,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->mailer->validateMailAddress($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..07fdfb189 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 } } }, @@ -474,6 +494,9 @@ "dateRange": { "type": "boolean" }, + "confirmationEmailRecipient": { + "type": "boolean" + }, "maxAllowedFilesCount": { "type": "integer", "format": "int64" diff --git a/src/components/Questions/QuestionShort.vue b/src/components/Questions/QuestionShort.vue index b14514440..7417a828e 100644 --- a/src/components/Questions/QuestionShort.vue +++ b/src/components/Questions/QuestionShort.vue @@ -182,6 +182,7 @@ export default { if (validationType === 'regex') { // Make sure to also submit a regex (even if empty) this.onExtraSettingsChange({ + confirmationEmailRecipient: undefined, validationType, validationRegex: this.validationRegex, }) @@ -189,6 +190,7 @@ export default { // For all other types except regex we close the menu (for regex we keep it open to allow entering a regex) this.isValidationTypeMenuOpen = false this.onExtraSettingsChange({ + confirmationEmailRecipient: undefined, validationType: validationType === 'text' ? undefined : validationType, }) diff --git a/src/components/SidebarTabs/SettingsSidebarTab.vue b/src/components/SidebarTabs/SettingsSidebarTab.vue index b973b6e54..11a6af857 100644 --- a/src/components/SidebarTabs/SettingsSidebarTab.vue +++ b/src/components/SidebarTabs/SettingsSidebarTab.vue @@ -194,6 +194,132 @@ + + {{ t('forms', 'Send confirmation email to respondents') }} + +
+

+ {{ t('forms', 'Set up confirmation emails in three steps:') }} +

+
    +
  1. {{ t('forms', 'Add an email field to the form.') }}
  2. +
  3. + {{ + t( + 'forms', + 'Select which email field receives the confirmation email.', + ) + }} +
  4. +
  5. {{ t('forms', 'Customize the subject and message.') }}
  6. +
+ + + +
+ +

+ {{ selectedConfirmationEmailQuestionLabel }} +
+ {{ + t( + 'forms', + 'Selected automatically because this is the only email field in the form.', + ) + }} +

+ +
+

+ {{ + t( + 'forms', + 'Available placeholders: {formTitle}, {formDescription}, and field names like {name}.', + ) + }} +

+ + + + +
+ import { getCurrentUser } from '@nextcloud/auth' +import axios from '@nextcloud/axios' +import { showError } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' import { loadState } from '@nextcloud/initial-state' import moment from '@nextcloud/moment' +import { generateOcsUrl } from '@nextcloud/router' import { vOnClickOutside as ClickOutside } from '@vueuse/components' import NcButton from '@nextcloud/vue/components/NcButton' import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' @@ -266,6 +396,9 @@ export default { /** If custom submission message is shown as input or rendered markdown */ editMessage: false, svgLockOpen, + confirmationEmailSubject: this.form?.confirmationEmailSubject || '', + confirmationEmailBody: this.form?.confirmationEmailBody || '', + isSavingConfirmationEmailRecipient: false, } }, @@ -361,9 +494,120 @@ export default { submissionMessageHTML() { return this.$markdownit.render(this.form.submissionMessage || '') }, + + /** + * Placeholder text for email body + */ + emailBodyPlaceholder() { + return this.t( + 'forms', + 'Thank you for submitting the form "{formTitle}".', + { formTitle: this.form.title || '' }, + ) + }, + + emailQuestionCount() { + return this.confirmationEmailQuestions.length + }, + + confirmationEmailQuestions() { + const questions = this.form?.questions || [] + return questions.filter( + (question) => + question.type === 'short' + && question.extraSettings?.validationType === 'email', + ) + }, + + confirmationEmailRecipientCount() { + return this.confirmationEmailQuestions.filter( + (question) => question.extraSettings?.confirmationEmailRecipient, + ).length + }, + + selectedConfirmationEmailQuestion() { + const selectedQuestion = this.confirmationEmailQuestions.find( + (question) => question.extraSettings?.confirmationEmailRecipient, + ) + if (selectedQuestion) { + return selectedQuestion + } + + if (this.emailQuestionCount === 1) { + return this.confirmationEmailQuestions[0] + } + + return null + }, + + selectedConfirmationEmailQuestionId() { + return this.selectedConfirmationEmailQuestion?.id ?? '' + }, + + selectedConfirmationEmailQuestionLabel() { + if (!this.selectedConfirmationEmailQuestion) { + return '' + } + + return this.confirmationEmailQuestionLabel( + this.selectedConfirmationEmailQuestion, + ) + }, + + hasConfirmationEmailRecipientConflict() { + return this.confirmationEmailRecipientCount > 1 + }, + + requiresConfirmationEmailRecipientSelection() { + return ( + this.emailQuestionCount > 1 + && this.confirmationEmailRecipientCount !== 1 + ) + }, + + isConfirmationEmailConfigurationBlocked() { + return ( + this.form.confirmationEmailEnabled + && (this.emailQuestionCount === 0 + || this.hasConfirmationEmailRecipientConflict + || this.requiresConfirmationEmailRecipientSelection) + ) + }, + }, + + watch: { + form: { + handler(newForm) { + this.confirmationEmailSubject = + newForm?.confirmationEmailSubject || '' + this.confirmationEmailBody = newForm?.confirmationEmailBody || '' + }, + + deep: true, + }, + + confirmationEmailQuestions: { + async handler() { + if ( + this.form.confirmationEmailEnabled + && this.emailQuestionCount === 1 + && this.confirmationEmailRecipientCount !== 1 + ) { + await this.saveConfirmationEmailRecipient( + this.confirmationEmailQuestions[0].id, + ) + } + }, + + deep: true, + }, }, methods: { + confirmationEmailQuestionLabel(question) { + return question.text || t('forms', 'Untitled question') + }, + /** * Save Form-Properties * @@ -456,6 +700,124 @@ export default { } }, + onConfirmationEmailEnabledChange(checked) { + this.$emit('update:formProp', 'confirmationEmailEnabled', checked) + }, + + onConfirmationEmailSubjectChange() { + this.$emit( + 'update:formProp', + 'confirmationEmailSubject', + this.confirmationEmailSubject, + ) + }, + + onConfirmationEmailBodyInput(event) { + this.confirmationEmailBody = event.target.value + }, + + onConfirmationEmailBodyChange({ target }) { + this.$emit('update:formProp', 'confirmationEmailBody', target.value) + }, + + async onConfirmationEmailRecipientSelectionChange(event) { + const questionId = Number.parseInt(event.target.value, 10) + if (Number.isNaN(questionId)) { + return + } + + await this.saveConfirmationEmailRecipient(questionId) + }, + + async saveConfirmationEmailRecipient(selectedQuestionId) { + if (!this.form?.id) { + return + } + + const emailQuestions = this.confirmationEmailQuestions + const pendingUpdates = emailQuestions + .map((question) => { + const nextExtraSettings = { ...(question.extraSettings || {}) } + if (selectedQuestionId === question.id) { + nextExtraSettings.confirmationEmailRecipient = true + } else { + delete nextExtraSettings.confirmationEmailRecipient + } + + const hasRecipientFlag = + !!question.extraSettings?.confirmationEmailRecipient + const shouldHaveRecipientFlag = + selectedQuestionId === question.id + + if (hasRecipientFlag === shouldHaveRecipientFlag) { + return null + } + + return { + question, + nextExtraSettings, + } + }) + .filter((update) => update !== null) + + if (pendingUpdates.length === 0) { + return + } + + const previousExtraSettings = new Map( + pendingUpdates.map(({ question }) => [ + question.id, + { ...(question.extraSettings || {}) }, + ]), + ) + + pendingUpdates.forEach(({ question, nextExtraSettings }) => { + const localQuestion = this.form.questions.find( + (searchQuestion) => searchQuestion.id === question.id, + ) + if (localQuestion) { + localQuestion.extraSettings = nextExtraSettings + } + }) + + this.isSavingConfirmationEmailRecipient = true + + try { + await Promise.all( + pendingUpdates.map(({ question, nextExtraSettings }) => + axios.patch( + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions/{questionId}', + { + id: this.form.id, + questionId: question.id, + }, + ), + { + keyValuePairs: { + extraSettings: nextExtraSettings, + }, + }, + ), + ), + ) + emit('forms:last-updated:set', this.form.id) + } catch { + pendingUpdates.forEach(({ question }) => { + const localQuestion = this.form.questions.find( + (searchQuestion) => searchQuestion.id === question.id, + ) + if (localQuestion) { + localQuestion.extraSettings = + previousExtraSettings.get(question.id) || {} + } + }) + showError(t('forms', 'Error while saving question')) + } finally { + this.isSavingConfirmationEmailRecipient = false + } + }, + /** * Datepicker timestamp to string * @@ -550,4 +912,81 @@ export default { } } } + +.confirmation-email { + &__hint { + color: var(--color-text-maxcontrast); + font-size: 13px; + margin-bottom: 8px; + } + + &__steps { + margin: 0 0 12px; + padding-inline-start: 20px; + color: var(--color-text-maxcontrast); + font-size: 13px; + } + + &__recipient { + margin-bottom: 12px; + } + + &__recipient-summary, + &__placeholder-hint { + color: var(--color-text-maxcontrast); + font-size: 13px; + margin-top: 8px; + } + + &__label { + display: block; + margin-top: 12px; + margin-bottom: 4px; + font-weight: 600; + } + + &__input { + width: 100%; + padding: 8px; + margin-bottom: 12px; + border: 2px solid var(--color-border-maxcontrast); + border-radius: var(--border-radius-large); + font-size: 14px; + + &:focus { + outline: none; + border-color: var(--color-primary-element); + } + } + + &__select { + width: 100%; + padding: 8px; + border: 2px solid var(--color-border-maxcontrast); + border-radius: var(--border-radius-large); + font-size: 14px; + background: var(--color-main-background); + + &:focus { + outline: none; + border-color: var(--color-primary-element); + } + } + + &__textarea { + width: 100%; + min-height: 120px; + padding: 8px; + border: 2px solid var(--color-border-maxcontrast); + border-radius: var(--border-radius-large); + font-size: 14px; + line-height: 1.5; + resize: vertical; + + &:focus { + outline: none; + border-color: var(--color-primary-element); + } + } +} diff --git a/tests/Integration/Api/ApiV3Test.php b/tests/Integration/Api/ApiV3Test.php index 9cfe3394f..2da9d5920 100644 --- a/tests/Integration/Api/ApiV3Test.php +++ b/tests/Integration/Api/ApiV3Test.php @@ -396,6 +396,10 @@ public function dataGetNewForm() { 'fileFormat' => null, 'maxSubmissions' => null, 'isMaxSubmissionsReached' => false, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, + 'confirmationEmailRecipient' => null, ] ] ]; @@ -529,6 +533,10 @@ public function dataGetFullForm() { 'fileFormat' => null, 'maxSubmissions' => null, 'isMaxSubmissionsReached' => false, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, + 'confirmationEmailRecipient' => null, ] ] ]; diff --git a/tests/Integration/Api/RespectAdminSettingsTest.php b/tests/Integration/Api/RespectAdminSettingsTest.php index 09d8ff7d1..9bc76a685 100644 --- a/tests/Integration/Api/RespectAdminSettingsTest.php +++ b/tests/Integration/Api/RespectAdminSettingsTest.php @@ -145,6 +145,10 @@ private static function sharedTestForms(): array { 'submissionCount' => 0, 'maxSubmissions' => null, 'isMaxSubmissionsReached' => false, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, + 'confirmationEmailRecipient' => null, ], ]; } diff --git a/tests/Unit/Controller/ApiControllerTest.php b/tests/Unit/Controller/ApiControllerTest.php index 45319cb9c..454cbc183 100644 --- a/tests/Unit/Controller/ApiControllerTest.php +++ b/tests/Unit/Controller/ApiControllerTest.php @@ -516,6 +516,10 @@ public function dataTestCreateNewForm() { 'lockedBy' => null, 'lockedUntil' => null, 'maxSubmissions' => null, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, + 'confirmationEmailRecipient' => null, ]] ]; } @@ -534,7 +538,10 @@ public function testCreateNewForm($expectedForm) { $expected['id'] = null; // TODO fix test, currently unset because behaviour has changed $expected['state'] = null; - $expected['lastUpdated'] = null; + $expected['lastUpdated'] = 0; + $expected['confirmationEmailEnabled'] = false; + $expected['confirmationEmailSubject'] = null; + $expected['confirmationEmailBody'] = null; $this->formMapper->expects($this->once()) ->method('insert') ->with(self::callback(self::createFormValidator($expected))) diff --git a/tests/Unit/FormsMigratorTest.php b/tests/Unit/FormsMigratorTest.php index 454f6d2ef..35a2ccf25 100644 --- a/tests/Unit/FormsMigratorTest.php +++ b/tests/Unit/FormsMigratorTest.php @@ -111,6 +111,10 @@ public function dataExport() { "showExpiration": false, "lastUpdated": 123456789, "submissionMessage": "Back to website", + "confirmationEmailEnabled": false, + "confirmationEmailSubject": null, + "confirmationEmailBody": null, + "confirmationEmailRecipient": null, "questions": [ { "id": 14, diff --git a/tests/Unit/Service/FormsServiceTest.php b/tests/Unit/Service/FormsServiceTest.php index 15b0021b9..bdc312808 100644 --- a/tests/Unit/Service/FormsServiceTest.php +++ b/tests/Unit/Service/FormsServiceTest.php @@ -32,6 +32,8 @@ function microtime(bool|float $asFloat = false) { use OCA\Forms\Activity\ActivityManager; use OCA\Forms\Constants; +use OCA\Forms\Db\Answer; +use OCA\Forms\Db\AnswerMapper; use OCA\Forms\Db\Form; use OCA\Forms\Db\FormMapper; use OCA\Forms\Db\Option; @@ -56,6 +58,8 @@ function microtime(bool|float $asFloat = false) { use OCP\IUser; use OCP\IUserManager; use OCP\IUserSession; +use OCP\Mail\IMailer; +use OCP\Mail\IMessage; use OCP\Security\ISecureRandom; use OCP\Share\IShare; use PHPUnit\Framework\MockObject\MockObject; @@ -110,6 +114,12 @@ class FormsServiceTest extends TestCase { /** @var LoggerInterface|MockObject */ private $logger; + /** @var IMailer|MockObject */ + private $mailer; + + /** @var AnswerMapper|MockObject */ + private $answerMapper; + public function setUp(): void { parent::setUp(); $this->activityManager = $this->createMock(ActivityManager::class); @@ -120,6 +130,8 @@ public function setUp(): void { $this->submissionMapper = $this->createMock(SubmissionMapper::class); $this->configService = $this->createMock(ConfigService::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->mailer = $this->createMock(IMailer::class); + $this->answerMapper = $this->createMock(AnswerMapper::class); $this->groupManager = $this->createMock(IGroupManager::class); $this->userManager = $this->createMock(IUserManager::class); $this->secureRandom = $this->createMock(ISecureRandom::class); @@ -139,8 +151,11 @@ public function setUp(): void { $this->l10n = $this->createMock(IL10N::class); $this->l10n->expects($this->any()) ->method('t') - ->will($this->returnCallback(function (string $identity) { - return $identity; + ->will($this->returnCallback(function (string $text, array $params = []) { + if (!empty($params)) { + return sprintf($text, ...$params); + } + return $text; })); $this->formsService = new FormsService( @@ -160,7 +175,8 @@ public function setUp(): void { $this->l10n, $this->logger, \OCP\Server::get(IEventDispatcher::class), - $this->logger, + $this->mailer, + $this->answerMapper, ); } @@ -257,6 +273,10 @@ public function dataGetForm() { 'lockedUntil' => null, 'maxSubmissions' => null, 'isMaxSubmissionsReached' => false, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, + 'confirmationEmailRecipient' => null, ]] ]; } @@ -478,6 +498,10 @@ public function dataGetPublicForm() { 'lockedUntil' => null, 'maxSubmissions' => null, 'isMaxSubmissionsReached' => false, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, + 'confirmationEmailRecipient' => null, ]] ]; } @@ -653,7 +677,8 @@ public function testGetPermissions_NotLoggedIn() { $this->l10n, $this->logger, \OCP\Server::get(IEventDispatcher::class), - $this->logger, + $this->mailer, + $this->answerMapper, ); $form = new Form(); @@ -894,7 +919,8 @@ public function testPublicCanSubmit() { $this->l10n, $this->logger, \OCP\Server::get(IEventDispatcher::class), - $this->logger, + $this->mailer, + $this->answerMapper, ); $this->assertEquals(true, $formsService->canSubmit($form)); @@ -1007,7 +1033,8 @@ public function testHasUserAccess_NotLoggedIn() { $this->l10n, $this->logger, \OCP\Server::get(IEventDispatcher::class), - $this->logger, + $this->mailer, + $this->answerMapper, ); $form = new Form(); @@ -1240,7 +1267,8 @@ public function testNotifyNewSubmission($shares, $shareNotifications) { $this->l10n, $this->logger, $eventDispatcher, - $this->logger, + $this->mailer, + $this->answerMapper, ]) ->getMock(); @@ -1260,6 +1288,906 @@ public function testNotifyNewSubmission($shares, $shareNotifications) { $formsService->notifyNewSubmission($form, $submission); } + public function testNotifyNewSubmissionDoesNotSendConfirmationEmailIfDisabled(): void { + $submission = new Submission(); + $submission->setId(99); + $submission->setUserId('someUser'); + + $form = Form::fromParams([ + 'id' => 42, + 'ownerId' => 'ownerUser', + 'confirmationEmailEnabled' => false, + ]); + + $eventDispatcher = $this->createMock(IEventDispatcher::class); + $eventDispatcher->expects($this->once())->method('dispatchTyped')->withAnyParameters(); + + $formsService = $this->getMockBuilder(FormsService::class) + ->onlyMethods(['getShares']) + ->setConstructorArgs([ + $this->createMock(IUserSession::class), + $this->activityManager, + $this->formMapper, + $this->optionMapper, + $this->questionMapper, + $this->shareMapper, + $this->submissionMapper, + $this->configService, + $this->groupManager, + $this->userManager, + $this->secureRandom, + $this->circlesService, + $this->storage, + $this->l10n, + $this->logger, + $eventDispatcher, + $this->mailer, + $this->answerMapper, + ]) + ->getMock(); + + $formsService->method('getShares')->willReturn([]); + + $this->mailer->expects($this->never())->method('send'); + + $formsService->notifyNewSubmission($form, $submission); + } + + public function testNotifyNewSubmissionSendsConfirmationEmailWithPlaceholders(): void { + $submission = new Submission(); + $submission->setId(99); + $submission->setUserId('someUser'); + + $form = Form::fromParams([ + 'id' => 42, + 'ownerId' => 'ownerUser', + 'title' => 'My Form', + 'description' => 'My Desc', + 'confirmationEmailEnabled' => true, + 'confirmationEmailSubject' => 'Thanks {name}', + 'confirmationEmailBody' => 'Hello {name}', + ]); + + $questions = [ + [ + 'id' => 1, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'text' => 'Email', + 'name' => 'email', + 'extraSettings' => ['validationType' => 'email'], + ], + [ + 'id' => 2, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'text' => 'Name', + 'name' => 'name', + 'extraSettings' => ['validationType' => 'text'], + ], + ]; + + $emailAnswer = new Answer(); + $emailAnswer->setSubmissionId(99); + $emailAnswer->setQuestionId(1); + $emailAnswer->setText('respondent@example.com'); + + $nameAnswer = new Answer(); + $nameAnswer->setSubmissionId(99); + $nameAnswer->setQuestionId(2); + $nameAnswer->setText('Ada'); + + $this->answerMapper->expects($this->once()) + ->method('findBySubmission') + ->with(99) + ->willReturn([$emailAnswer, $nameAnswer]); + + $this->mailer->expects($this->once()) + ->method('validateMailAddress') + ->with('respondent@example.com') + ->willReturn(true); + + $message = $this->createMock(IMessage::class); + + $message->expects($this->once()) + ->method('setSubject') + ->with('Thanks Ada'); + $message->expects($this->once()) + ->method('setTo') + ->with(['respondent@example.com']); + $message->expects($this->once()) + ->method('setPlainBody') + ->with($this->callback(function (string $body): bool { + $this->assertStringContainsString('Hello Ada', $body); + return true; + })); + + $this->mailer->expects($this->once()) + ->method('createMessage') + ->willReturn($message); + + $this->mailer->expects($this->once()) + ->method('send') + ->with($message); + + $eventDispatcher = $this->createMock(IEventDispatcher::class); + $eventDispatcher->expects($this->once())->method('dispatchTyped')->withAnyParameters(); + + $formsService = $this->getMockBuilder(FormsService::class) + ->onlyMethods(['getShares', 'getQuestions']) + ->setConstructorArgs([ + $this->createMock(IUserSession::class), + $this->activityManager, + $this->formMapper, + $this->optionMapper, + $this->questionMapper, + $this->shareMapper, + $this->submissionMapper, + $this->configService, + $this->groupManager, + $this->userManager, + $this->secureRandom, + $this->circlesService, + $this->storage, + $this->l10n, + $this->logger, + $eventDispatcher, + $this->mailer, + $this->answerMapper, + ]) + ->getMock(); + + $formsService->method('getShares')->willReturn([]); + $formsService->method('getQuestions')->with(42)->willReturn($questions); + + $formsService->notifyNewSubmission($form, $submission); + } + + public function testNotifyNewSubmissionSendsConfirmationEmailWithFormPlaceholders(): void { + $submission = new Submission(); + $submission->setId(99); + $submission->setUserId('someUser'); + + $form = Form::fromParams([ + 'id' => 42, + 'ownerId' => 'ownerUser', + 'title' => 'My Form', + 'description' => 'My Desc', + 'confirmationEmailEnabled' => true, + 'confirmationEmailSubject' => 'Subject {formTitle}', + 'confirmationEmailBody' => 'Body {formDescription}', + ]); + + $questions = [ + [ + 'id' => 1, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'text' => 'Email', + 'name' => 'email', + 'extraSettings' => ['validationType' => 'email'], + ], + ]; + + $emailAnswer = new Answer(); + $emailAnswer->setSubmissionId(99); + $emailAnswer->setQuestionId(1); + $emailAnswer->setText('respondent@example.com'); + + $this->answerMapper->expects($this->once()) + ->method('findBySubmission') + ->with(99) + ->willReturn([$emailAnswer]); + + $this->mailer->expects($this->once()) + ->method('validateMailAddress') + ->with('respondent@example.com') + ->willReturn(true); + + $message = $this->createMock(IMessage::class); + $message->expects($this->once()) + ->method('setSubject') + ->with('Subject My Form'); + $message->expects($this->once()) + ->method('setTo') + ->with(['respondent@example.com']); + $message->expects($this->once()) + ->method('setPlainBody') + ->with('Body My Desc'); + + $this->mailer->expects($this->once()) + ->method('createMessage') + ->willReturn($message); + + $this->mailer->expects($this->once()) + ->method('send') + ->with($message); + + $eventDispatcher = $this->createMock(IEventDispatcher::class); + $eventDispatcher->expects($this->once())->method('dispatchTyped')->withAnyParameters(); + + $formsService = $this->getMockBuilder(FormsService::class) + ->onlyMethods(['getShares', 'getQuestions']) + ->setConstructorArgs([ + $this->createMock(IUserSession::class), + $this->activityManager, + $this->formMapper, + $this->optionMapper, + $this->questionMapper, + $this->shareMapper, + $this->submissionMapper, + $this->configService, + $this->groupManager, + $this->userManager, + $this->secureRandom, + $this->circlesService, + $this->storage, + $this->l10n, + $this->logger, + $eventDispatcher, + $this->mailer, + $this->answerMapper, + ]) + ->getMock(); + + $formsService->method('getShares')->willReturn([]); + $formsService->method('getQuestions')->with(42)->willReturn($questions); + + $formsService->notifyNewSubmission($form, $submission); + } + + public function testNotifyNewSubmissionConfirmationEmailUsesFirstEmailField(): void { + $submission = new Submission(); + $submission->setId(99); + $submission->setUserId('someUser'); + + $form = Form::fromParams([ + 'id' => 42, + 'ownerId' => 'ownerUser', + 'title' => 'My Form', + 'description' => 'My Desc', + 'confirmationEmailEnabled' => true, + 'confirmationEmailSubject' => 'Thanks', + 'confirmationEmailBody' => 'Hello', + ]); + + $questions = [ + [ + 'id' => 1, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'text' => 'Email 1', + 'name' => 'email1', + 'extraSettings' => ['validationType' => 'email', 'confirmationEmailRecipient' => true], + ], + [ + 'id' => 2, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'text' => 'Email 2', + 'name' => 'email2', + 'extraSettings' => ['validationType' => 'email'], + ], + ]; + + $emailAnswer1 = new Answer(); + $emailAnswer1->setSubmissionId(99); + $emailAnswer1->setQuestionId(1); + $emailAnswer1->setText('first@example.com'); + + $emailAnswer2 = new Answer(); + $emailAnswer2->setSubmissionId(99); + $emailAnswer2->setQuestionId(2); + $emailAnswer2->setText('second@example.com'); + + $this->answerMapper->expects($this->once()) + ->method('findBySubmission') + ->with(99) + ->willReturn([$emailAnswer1, $emailAnswer2]); + + $this->mailer->expects($this->once()) + ->method('validateMailAddress') + ->with('first@example.com') + ->willReturn(true); + + $message = $this->createMock(IMessage::class); + $message->expects($this->once()) + ->method('setTo') + ->with(['first@example.com']); + + $this->mailer->expects($this->once()) + ->method('createMessage') + ->willReturn($message); + + $this->mailer->expects($this->once()) + ->method('send') + ->with($message); + + $logged = []; + $this->logger->method('debug') + ->willReturnCallback(function (string $message, array $context) use (&$logged): void { + $logged[] = [$message, $context]; + }); + + $eventDispatcher = $this->createMock(IEventDispatcher::class); + $eventDispatcher->expects($this->once())->method('dispatchTyped')->withAnyParameters(); + + $formsService = $this->getMockBuilder(FormsService::class) + ->onlyMethods(['getShares', 'getQuestions']) + ->setConstructorArgs([ + $this->createMock(IUserSession::class), + $this->activityManager, + $this->formMapper, + $this->optionMapper, + $this->questionMapper, + $this->shareMapper, + $this->submissionMapper, + $this->configService, + $this->groupManager, + $this->userManager, + $this->secureRandom, + $this->circlesService, + $this->storage, + $this->l10n, + $this->logger, + $eventDispatcher, + $this->mailer, + $this->answerMapper, + ]) + ->getMock(); + + $formsService->method('getShares')->willReturn([]); + $formsService->method('getQuestions')->with(42)->willReturn($questions); + + $formsService->notifyNewSubmission($form, $submission); + } + + public function testNotifyNewSubmissionPlaceholderUsesQuestionTextWhenNameMissing(): void { + $submission = new Submission(); + $submission->setId(99); + $submission->setUserId('someUser'); + + $form = Form::fromParams([ + 'id' => 42, + 'ownerId' => 'ownerUser', + 'title' => 'My Form', + 'description' => 'My Desc', + 'confirmationEmailEnabled' => true, + 'confirmationEmailSubject' => 'Hello {fullname}', + 'confirmationEmailBody' => 'Body', + ]); + + $questions = [ + [ + 'id' => 1, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'text' => 'Email', + 'name' => 'email', + 'extraSettings' => ['validationType' => 'email'], + ], + [ + 'id' => 2, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'text' => 'Full Name?', + 'name' => '', + 'extraSettings' => ['validationType' => 'text'], + ], + ]; + + $emailAnswer = new Answer(); + $emailAnswer->setSubmissionId(99); + $emailAnswer->setQuestionId(1); + $emailAnswer->setText('respondent@example.com'); + + $nameAnswer = new Answer(); + $nameAnswer->setSubmissionId(99); + $nameAnswer->setQuestionId(2); + $nameAnswer->setText('Ada'); + + $this->answerMapper->expects($this->once()) + ->method('findBySubmission') + ->with(99) + ->willReturn([$emailAnswer, $nameAnswer]); + + $this->mailer->expects($this->once()) + ->method('validateMailAddress') + ->with('respondent@example.com') + ->willReturn(true); + + $message = $this->createMock(IMessage::class); + $message->expects($this->once()) + ->method('setSubject') + ->with('Hello Ada'); + + $this->mailer->expects($this->once()) + ->method('createMessage') + ->willReturn($message); + + $this->mailer->expects($this->once()) + ->method('send') + ->with($message); + + $eventDispatcher = $this->createMock(IEventDispatcher::class); + $eventDispatcher->expects($this->once())->method('dispatchTyped')->withAnyParameters(); + + $formsService = $this->getMockBuilder(FormsService::class) + ->onlyMethods(['getShares', 'getQuestions']) + ->setConstructorArgs([ + $this->createMock(IUserSession::class), + $this->activityManager, + $this->formMapper, + $this->optionMapper, + $this->questionMapper, + $this->shareMapper, + $this->submissionMapper, + $this->configService, + $this->groupManager, + $this->userManager, + $this->secureRandom, + $this->circlesService, + $this->storage, + $this->l10n, + $this->logger, + $eventDispatcher, + $this->mailer, + $this->answerMapper, + ]) + ->getMock(); + + $formsService->method('getShares')->willReturn([]); + $formsService->method('getQuestions')->with(42)->willReturn($questions); + + $formsService->notifyNewSubmission($form, $submission); + } + + public function testNotifyNewSubmissionSendsConfirmationEmailWithEmptySubjectAndBody(): void { + $submission = new Submission(); + $submission->setId(99); + $submission->setUserId('someUser'); + + $form = Form::fromParams([ + 'id' => 42, + 'ownerId' => 'ownerUser', + 'title' => 'My Form', + 'description' => 'My Desc', + 'confirmationEmailEnabled' => true, + 'confirmationEmailSubject' => '', + 'confirmationEmailBody' => '', + ]); + + $questions = [ + [ + 'id' => 1, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'text' => 'Email', + 'name' => 'email', + 'extraSettings' => ['validationType' => 'email'], + ], + ]; + + $emailAnswer = new Answer(); + $emailAnswer->setSubmissionId(99); + $emailAnswer->setQuestionId(1); + $emailAnswer->setText('respondent@example.com'); + + $this->answerMapper->expects($this->once()) + ->method('findBySubmission') + ->with(99) + ->willReturn([$emailAnswer]); + + $this->mailer->expects($this->once()) + ->method('validateMailAddress') + ->with('respondent@example.com') + ->willReturn(true); + + $message = $this->createMock(IMessage::class); + + $message->expects($this->once()) + ->method('setSubject') + ->with('Thank you for your submission'); + $message->expects($this->once()) + ->method('setTo') + ->with(['respondent@example.com']); + $message->expects($this->once()) + ->method('setPlainBody') + ->with($this->callback(function (string $body): bool { + $this->assertStringContainsString('Thank you for submitting the form "My Form"', $body); + return true; + })); + + $this->mailer->expects($this->once()) + ->method('createMessage') + ->willReturn($message); + + $this->mailer->expects($this->once()) + ->method('send') + ->with($message); + + $eventDispatcher = $this->createMock(IEventDispatcher::class); + $eventDispatcher->expects($this->once())->method('dispatchTyped')->withAnyParameters(); + + $formsService = $this->getMockBuilder(FormsService::class) + ->onlyMethods(['getShares', 'getQuestions']) + ->setConstructorArgs([ + $this->createMock(IUserSession::class), + $this->activityManager, + $this->formMapper, + $this->optionMapper, + $this->questionMapper, + $this->shareMapper, + $this->submissionMapper, + $this->configService, + $this->groupManager, + $this->userManager, + $this->secureRandom, + $this->circlesService, + $this->storage, + $this->l10n, + $this->logger, + $eventDispatcher, + $this->mailer, + $this->answerMapper, + ]) + ->getMock(); + + $formsService->method('getShares')->willReturn([]); + $formsService->method('getQuestions')->with(42)->willReturn($questions); + + $formsService->notifyNewSubmission($form, $submission); + } + + public function testNotifyNewSubmissionSendsConfirmationEmailToExplicitRecipientQuestion(): void { + $submission = new Submission(); + $submission->setId(99); + $submission->setUserId('someUser'); + + $form = Form::fromParams([ + 'id' => 42, + 'ownerId' => 'ownerUser', + 'confirmationEmailEnabled' => true, + 'confirmationEmailSubject' => 'Thanks', + 'confirmationEmailBody' => 'Hello', + ]); + + $questions = [ + [ + 'id' => 1, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'text' => 'Work email', + 'name' => 'workEmail', + 'extraSettings' => ['validationType' => 'email'], + ], + [ + 'id' => 2, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'text' => 'Private email', + 'name' => 'privateEmail', + 'extraSettings' => [ + 'validationType' => 'email', + 'confirmationEmailRecipient' => true, + ], + ], + ]; + + $workEmailAnswer = new Answer(); + $workEmailAnswer->setSubmissionId(99); + $workEmailAnswer->setQuestionId(1); + $workEmailAnswer->setText('work@example.com'); + + $privateEmailAnswer = new Answer(); + $privateEmailAnswer->setSubmissionId(99); + $privateEmailAnswer->setQuestionId(2); + $privateEmailAnswer->setText('private@example.com'); + + $this->answerMapper->expects($this->once()) + ->method('findBySubmission') + ->with(99) + ->willReturn([$workEmailAnswer, $privateEmailAnswer]); + + $this->mailer->expects($this->once()) + ->method('validateMailAddress') + ->with('private@example.com') + ->willReturn(true); + + $message = $this->createMock(IMessage::class); + $message->expects($this->once())->method('setSubject')->with('Thanks'); + $message->expects($this->once())->method('setPlainBody')->with('Hello'); + $message->expects($this->once())->method('setTo')->with(['private@example.com']); + + $this->mailer->expects($this->once()) + ->method('createMessage') + ->willReturn($message); + $this->mailer->expects($this->once()) + ->method('send') + ->with($message); + + $eventDispatcher = $this->createMock(IEventDispatcher::class); + $eventDispatcher->expects($this->once())->method('dispatchTyped')->withAnyParameters(); + + $formsService = $this->getMockBuilder(FormsService::class) + ->onlyMethods(['getShares', 'getQuestions']) + ->setConstructorArgs([ + $this->createMock(IUserSession::class), + $this->activityManager, + $this->formMapper, + $this->optionMapper, + $this->questionMapper, + $this->shareMapper, + $this->submissionMapper, + $this->configService, + $this->groupManager, + $this->userManager, + $this->secureRandom, + $this->circlesService, + $this->storage, + $this->l10n, + $this->logger, + $eventDispatcher, + $this->mailer, + $this->answerMapper, + ]) + ->getMock(); + + $formsService->method('getShares')->willReturn([]); + $formsService->method('getQuestions')->with(42)->willReturn($questions); + + $formsService->notifyNewSubmission($form, $submission); + } + + public function testNotifyNewSubmissionDoesNotSendConfirmationEmailWithoutExplicitRecipientWhenMultipleEmailFieldsExist(): void { + $submission = new Submission(); + $submission->setId(99); + $submission->setUserId('someUser'); + + $form = Form::fromParams([ + 'id' => 42, + 'ownerId' => 'ownerUser', + 'confirmationEmailEnabled' => true, + 'confirmationEmailSubject' => 'Thanks', + 'confirmationEmailBody' => 'Hello', + ]); + + $questions = [ + [ + 'id' => 1, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'text' => 'Work email', + 'name' => 'workEmail', + 'extraSettings' => ['validationType' => 'email'], + ], + [ + 'id' => 2, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'text' => 'Private email', + 'name' => 'privateEmail', + 'extraSettings' => ['validationType' => 'email'], + ], + ]; + + $this->answerMapper->expects($this->once()) + ->method('findBySubmission') + ->with(99) + ->willReturn([]); + + $this->mailer->expects($this->never())->method('validateMailAddress'); + $this->logger->expects($this->once()) + ->method('debug') + ->with( + 'No confirmation email recipient question is available', + [ + 'formId' => 42, + 'submissionId' => 99, + ] + ); + $this->mailer->expects($this->never())->method('send'); + + $eventDispatcher = $this->createMock(IEventDispatcher::class); + $eventDispatcher->expects($this->once())->method('dispatchTyped')->withAnyParameters(); + + $formsService = $this->getMockBuilder(FormsService::class) + ->onlyMethods(['getShares', 'getQuestions']) + ->setConstructorArgs([ + $this->createMock(IUserSession::class), + $this->activityManager, + $this->formMapper, + $this->optionMapper, + $this->questionMapper, + $this->shareMapper, + $this->submissionMapper, + $this->configService, + $this->groupManager, + $this->userManager, + $this->secureRandom, + $this->circlesService, + $this->storage, + $this->l10n, + $this->logger, + $eventDispatcher, + $this->mailer, + $this->answerMapper, + ]) + ->getMock(); + + $formsService->method('getShares')->willReturn([]); + $formsService->method('getQuestions')->with(42)->willReturn($questions); + + $formsService->notifyNewSubmission($form, $submission); + } + + public function testNotifyNewSubmissionDoesNotSendConfirmationEmailWhenNoEmailFound(): void { + $submission = new Submission(); + $submission->setId(99); + $submission->setUserId('someUser'); + + $form = Form::fromParams([ + 'id' => 42, + 'ownerId' => 'ownerUser', + 'title' => 'My Form', + 'confirmationEmailEnabled' => true, + 'confirmationEmailSubject' => 'Thanks', + 'confirmationEmailBody' => 'Hello', + ]); + + $questions = [ + [ + 'id' => 1, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'text' => 'Name', + 'name' => 'name', + 'extraSettings' => ['validationType' => 'text'], + ], + ]; + + $nameAnswer = new Answer(); + $nameAnswer->setSubmissionId(99); + $nameAnswer->setQuestionId(1); + $nameAnswer->setText('John'); + + $this->answerMapper->expects($this->once()) + ->method('findBySubmission') + ->with(99) + ->willReturn([$nameAnswer]); + + $this->logger->expects($this->once()) + ->method('debug') + ->with( + 'No confirmation email recipient question is available', + [ + 'formId' => 42, + 'submissionId' => 99, + ] + ); + + $this->mailer->expects($this->never())->method('send'); + + $eventDispatcher = $this->createMock(IEventDispatcher::class); + $eventDispatcher->expects($this->once())->method('dispatchTyped')->withAnyParameters(); + + $formsService = $this->getMockBuilder(FormsService::class) + ->onlyMethods(['getShares', 'getQuestions']) + ->setConstructorArgs([ + $this->createMock(IUserSession::class), + $this->activityManager, + $this->formMapper, + $this->optionMapper, + $this->questionMapper, + $this->shareMapper, + $this->submissionMapper, + $this->configService, + $this->groupManager, + $this->userManager, + $this->secureRandom, + $this->circlesService, + $this->storage, + $this->l10n, + $this->logger, + $eventDispatcher, + $this->mailer, + $this->answerMapper, + ]) + ->getMock(); + + $formsService->method('getShares')->willReturn([]); + $formsService->method('getQuestions')->with(42)->willReturn($questions); + + $formsService->notifyNewSubmission($form, $submission); + } + + public function testNotifyNewSubmissionHandlesEmailException(): void { + $submission = new Submission(); + $submission->setId(99); + $submission->setUserId('someUser'); + + $form = Form::fromParams([ + 'id' => 42, + 'ownerId' => 'ownerUser', + 'title' => 'My Form', + 'confirmationEmailEnabled' => true, + 'confirmationEmailSubject' => 'Thanks', + 'confirmationEmailBody' => 'Hello', + ]); + + $questions = [ + [ + 'id' => 1, + 'type' => Constants::ANSWER_TYPE_SHORT, + 'text' => 'Email', + 'name' => 'email', + 'extraSettings' => ['validationType' => 'email'], + ], + ]; + + $emailAnswer = new Answer(); + $emailAnswer->setSubmissionId(99); + $emailAnswer->setQuestionId(1); + $emailAnswer->setText('respondent@example.com'); + + $this->answerMapper->expects($this->once()) + ->method('findBySubmission') + ->with(99) + ->willReturn([$emailAnswer]); + + $this->mailer->expects($this->once()) + ->method('validateMailAddress') + ->with('respondent@example.com') + ->willReturn(true); + + $message = $this->createMock(IMessage::class); + $message->expects($this->once())->method('setSubject')->with('Thanks'); + $message->expects($this->once())->method('setPlainBody')->with('Hello'); + $message->expects($this->once())->method('setTo')->with(['respondent@example.com']); + + $this->mailer->expects($this->once()) + ->method('createMessage') + ->willReturn($message); + + $exception = new \Exception('Mail server error'); + $this->mailer->expects($this->once()) + ->method('send') + ->with($message) + ->willThrowException($exception); + + $this->logger->expects($this->once()) + ->method('error') + ->with( + 'Error while sending confirmation email', + $this->callback(function (array $context): bool { + $this->assertArrayHasKey('exception', $context); + $this->assertInstanceOf(\Exception::class, $context['exception']); + $this->assertEquals(42, $context['formId']); + $this->assertEquals(99, $context['submissionId']); + return true; + }) + ); + + $eventDispatcher = $this->createMock(IEventDispatcher::class); + $eventDispatcher->expects($this->once())->method('dispatchTyped')->withAnyParameters(); + + $formsService = $this->getMockBuilder(FormsService::class) + ->onlyMethods(['getShares', 'getQuestions']) + ->setConstructorArgs([ + $this->createMock(IUserSession::class), + $this->activityManager, + $this->formMapper, + $this->optionMapper, + $this->questionMapper, + $this->shareMapper, + $this->submissionMapper, + $this->configService, + $this->groupManager, + $this->userManager, + $this->secureRandom, + $this->circlesService, + $this->storage, + $this->l10n, + $this->logger, + $eventDispatcher, + $this->mailer, + $this->answerMapper, + ]) + ->getMock(); + + $formsService->method('getShares')->willReturn([]); + $formsService->method('getQuestions')->with(42)->willReturn($questions); + + $formsService->notifyNewSubmission($form, $submission); + } + /** * @dataProvider dataAreExtraSettingsValid * @@ -1293,6 +2221,14 @@ public function dataAreExtraSettingsValid() { 'questionType' => Constants::ANSWER_TYPE_MULTIPLE, 'expected' => true ], + 'valid-confirmation-recipient' => [ + 'extraSettings' => [ + 'confirmationEmailRecipient' => true, + 'validationType' => 'email', + ], + 'questionType' => Constants::ANSWER_TYPE_SHORT, + 'expected' => true + ], 'valid-options-limit' => [ 'extraSettings' => [ 'optionsLimitMax' => 3,