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/Db/Form.php b/lib/Db/Form.php index fe1637eda..1532adc11 100644 --- a/lib/Db/Form.php +++ b/lib/Db/Form.php @@ -51,6 +51,12 @@ * @method void setLockedBy(string|null $value) * @method int getLockedUntil() * @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) */ class Form extends Entity { protected $hash; @@ -71,6 +77,9 @@ class Form extends Entity { protected $state; protected $lockedBy; protected $lockedUntil; + protected $confirmationEmailEnabled; + protected $confirmationEmailSubject; + protected $confirmationEmailBody; /** * Form constructor. @@ -86,6 +95,7 @@ public function __construct() { $this->addType('state', 'integer'); $this->addType('lockedBy', 'string'); $this->addType('lockedUntil', 'integer'); + $this->addType('confirmationEmailEnabled', 'boolean'); } // JSON-Decoding of access-column. @@ -159,6 +169,9 @@ public function setAccess(array $access): void { * state: 0|1|2, * lockedBy: ?string, * lockedUntil: ?int, + * confirmationEmailEnabled: bool, + * confirmationEmailSubject: ?string, + * confirmationEmailBody: ?string, * } */ public function read() { @@ -182,6 +195,9 @@ public function read() { 'state' => $this->getState(), 'lockedBy' => $this->getLockedBy(), 'lockedUntil' => $this->getLockedUntil(), + 'confirmationEmailEnabled' => (bool)$this->getConfirmationEmailEnabled(), + 'confirmationEmailSubject' => $this->getConfirmationEmailSubject(), + 'confirmationEmailBody' => $this->getConfirmationEmailBody(), ]; } } diff --git a/lib/Migration/Version050202Date20251217203121.php b/lib/Migration/Version050202Date20251217203121.php new file mode 100644 index 000000000..6cfdf48d8 --- /dev/null +++ b/lib/Migration/Version050202Date20251217203121.php @@ -0,0 +1,58 @@ +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, + ]); + } + + return $schema; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index f4833aa50..e390eb788 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -136,6 +136,9 @@ * shares: list, * submissionCount?: int, * submissionMessage: ?string, + * confirmationEmailEnabled: bool, + * confirmationEmailSubject: ?string, + * confirmationEmailBody: ?string, * } * * @psalm-type FormsUploadedFile = array{ diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 9a4c2d546..59f5cf007 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(); } @@ -737,6 +741,151 @@ 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()); + + // Build a map of question IDs to questions and answers + $questionMap = []; + foreach ($questions as $question) { + $questionMap[$question['id']] = $question; + } + + $answerMap = []; + foreach ($answers as $answer) { + $questionId = $answer->getQuestionId(); + if (!isset($answerMap[$questionId])) { + $answerMap[$questionId] = []; + } + $answerMap[$questionId][] = $answer->getText(); + } + + // Find email address from answers + $recipientEmail = null; + foreach ($questions as $question) { + if ($question['type'] !== Constants::ANSWER_TYPE_SHORT) { + continue; + } + + $extraSettings = (array)($question['extraSettings'] ?? []); + $validationType = $extraSettings['validationType'] ?? null; + if ($validationType !== 'email') { + continue; + } + + $questionId = $question['id']; + if (empty($answerMap[$questionId])) { + continue; + } + + $emailValue = $answerMap[$questionId][0]; + if ($this->mailer->validateMailAddress($emailValue)) { + $recipientEmail = $emailValue; + break; + } + } + + // If no email found, cannot send confirmation + if (empty($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]); + + // Set from address to form owner or system default + $owner = $this->userManager->get($form->getOwnerId()); + if ($owner instanceof IUser) { + $ownerEmail = $owner->getEMailAddress(); + if (!empty($ownerEmail)) { + $message->setFrom([$ownerEmail => $owner->getDisplayName()]); + } + } + + $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(), + ] + ); + } } /** diff --git a/openapi.json b/openapi.json index 1e8e8bc83..2385781ba 100644 --- a/openapi.json +++ b/openapi.json @@ -117,7 +117,10 @@ "lockedBy", "lockedUntil", "shares", - "submissionMessage" + "submissionMessage", + "confirmationEmailEnabled", + "confirmationEmailSubject", + "confirmationEmailBody" ], "properties": { "id": { @@ -222,6 +225,17 @@ "submissionMessage": { "type": "string", "nullable": true + }, + "confirmationEmailEnabled": { + "type": "boolean" + }, + "confirmationEmailSubject": { + "type": "string", + "nullable": true + }, + "confirmationEmailBody": { + "type": "string", + "nullable": true } } }, diff --git a/src/components/SidebarTabs/SettingsSidebarTab.vue b/src/components/SidebarTabs/SettingsSidebarTab.vue index 15bcea0d6..490a02dff 100644 --- a/src/components/SidebarTabs/SettingsSidebarTab.vue +++ b/src/components/SidebarTabs/SettingsSidebarTab.vue @@ -168,6 +168,47 @@ + + {{ t('forms', 'Send confirmation email to respondents') }} + +
+

+ {{ + t( + 'forms', + 'Requires an email field in the form. Available placeholders: {formTitle}, {formDescription}, and field names like {name}.', + ) + }} +

+ + + + +
+ diff --git a/tests/Integration/Api/ApiV3Test.php b/tests/Integration/Api/ApiV3Test.php index 46c345d6c..5a0d15085 100644 --- a/tests/Integration/Api/ApiV3Test.php +++ b/tests/Integration/Api/ApiV3Test.php @@ -394,6 +394,9 @@ public function dataGetNewForm() { 'submissionMessage' => null, 'fileId' => null, 'fileFormat' => null, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, ] ] ]; @@ -522,6 +525,9 @@ public function dataGetFullForm() { 'submissionCount' => 3, 'fileId' => null, 'fileFormat' => null, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, ] ] ]; diff --git a/tests/Integration/Api/RespectAdminSettingsTest.php b/tests/Integration/Api/RespectAdminSettingsTest.php index 48235ba09..79995884a 100644 --- a/tests/Integration/Api/RespectAdminSettingsTest.php +++ b/tests/Integration/Api/RespectAdminSettingsTest.php @@ -143,6 +143,9 @@ private static function sharedTestForms(): array { ], 'canSubmit' => true, 'submissionCount' => 0, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, ], ]; } diff --git a/tests/Unit/Controller/ApiControllerTest.php b/tests/Unit/Controller/ApiControllerTest.php index e1b1d5b00..db71f1076 100644 --- a/tests/Unit/Controller/ApiControllerTest.php +++ b/tests/Unit/Controller/ApiControllerTest.php @@ -388,6 +388,9 @@ public function dataTestCreateNewForm() { 'allowEditSubmissions' => false, 'lockedBy' => null, 'lockedUntil' => null, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, ]] ]; } @@ -406,7 +409,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 1e7fd97b5..03f0b8168 100644 --- a/tests/Unit/FormsMigratorTest.php +++ b/tests/Unit/FormsMigratorTest.php @@ -110,6 +110,9 @@ public function dataExport() { "showExpiration": false, "lastUpdated": 123456789, "submissionMessage": "Back to website", + "confirmationEmailEnabled": false, + "confirmationEmailSubject": null, + "confirmationEmailBody": null, "questions": [ { "id": 14, diff --git a/tests/Unit/Service/FormsServiceTest.php b/tests/Unit/Service/FormsServiceTest.php index 8953cdd86..c7b6caeab 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, ); } @@ -253,6 +269,9 @@ public function dataGetForm() { 'allowEditSubmissions' => false, 'lockedBy' => null, 'lockedUntil' => null, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, ]] ]; } @@ -472,6 +491,9 @@ public function dataGetPublicForm() { 'allowEditSubmissions' => false, 'lockedBy' => null, 'lockedUntil' => null, + 'confirmationEmailEnabled' => false, + 'confirmationEmailSubject' => null, + 'confirmationEmailBody' => null, ]] ]; } @@ -647,7 +669,8 @@ public function testGetPermissions_NotLoggedIn() { $this->l10n, $this->logger, \OCP\Server::get(IEventDispatcher::class), - $this->logger, + $this->mailer, + $this->answerMapper, ); $form = new Form(); @@ -888,7 +911,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)); @@ -1001,7 +1025,8 @@ public function testHasUserAccess_NotLoggedIn() { $this->l10n, $this->logger, \OCP\Server::get(IEventDispatcher::class), - $this->logger, + $this->mailer, + $this->answerMapper, ); $form = new Form(); @@ -1234,7 +1259,8 @@ public function testNotifyNewSubmission($shares, $shareNotifications) { $this->l10n, $this->logger, $eventDispatcher, - $this->logger, + $this->mailer, + $this->answerMapper, ]) ->getMock(); @@ -1254,6 +1280,456 @@ 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); + + $ownerUser = $this->createMock(IUser::class); + $ownerUser->expects($this->once())->method('getEMailAddress')->willReturn('owner@example.com'); + $ownerUser->expects($this->once())->method('getDisplayName')->willReturn('Owner'); + $this->userManager->expects($this->once())->method('get')->with('ownerUser')->willReturn($ownerUser); + + $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('setFrom') + ->with(['owner@example.com' => 'Owner']); + $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 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); + + $ownerUser = $this->createMock(IUser::class); + $ownerUser->expects($this->once())->method('getEMailAddress')->willReturn('owner@example.com'); + $ownerUser->expects($this->once())->method('getDisplayName')->willReturn('Owner'); + $this->userManager->expects($this->once())->method('get')->with('ownerUser')->willReturn($ownerUser); + + $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('setFrom') + ->with(['owner@example.com' => 'Owner']); + $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 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 valid email address found in submission for confirmation email', + [ + '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); + + $ownerUser = $this->createMock(IUser::class); + $ownerUser->expects($this->once())->method('getEMailAddress')->willReturn('owner@example.com'); + $ownerUser->expects($this->once())->method('getDisplayName')->willReturn('Owner'); + $this->userManager->expects($this->once())->method('get')->with('ownerUser')->willReturn($ownerUser); + + $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']); + $message->expects($this->once())->method('setFrom')->with(['owner@example.com' => 'Owner']); + + $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 *