diff --git a/CHANGELOG.md b/CHANGELOG.md index 9738197..5eba4cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [UNRELEASED] +### Added + +- Add an "Email" form destination, sending a customized email when a form is submitted + ### Fixed - Fix hidden question not available in anonymous forms diff --git a/README.md b/README.md index 5eec7cf..f69e6d8 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ This question won't be displayed to the users. ![ldap select question](screenshots/ldap-select.png) +### Email destination + +![email destination](screenshots/email-destination.png) + ## Contributing * Open a ticket for each bug/feature so it can be discussed diff --git a/public/css/advancedforms.css b/public/css/advancedforms.css index f8f9427..4d51870 100644 --- a/public/css/advancedforms.css +++ b/public/css/advancedforms.css @@ -93,3 +93,16 @@ max-width: none; margin-top: 0; } + +/* Email destination: "Body" label on the line of the "Auto config" switch */ +.advancedforms-email-body { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; +} +.advancedforms-email-body > [data-glpi-itildestination-field] { + display: contents; +} +.advancedforms-email-body > [data-glpi-itildestination-field] > [data-glpi-itildestination-field-configs] { + grid-column: 1 / -1; +} diff --git a/screenshots/config.png b/screenshots/config.png index b298308..e9e8d3a 100644 Binary files a/screenshots/config.png and b/screenshots/config.png differ diff --git a/screenshots/email-destination.png b/screenshots/email-destination.png new file mode 100644 index 0000000..9d8db4e Binary files /dev/null and b/screenshots/email-destination.png differ diff --git a/src/Model/Destination/Email/EmailAddressesField.php b/src/Model/Destination/Email/EmailAddressesField.php new file mode 100644 index 0000000..dace8cc --- /dev/null +++ b/src/Model/Destination/Email/EmailAddressesField.php @@ -0,0 +1,170 @@ + + */ + public function getEmailAddresses(SimpleValueConfig $config): array + { + return array_values(array_filter( + $this->getConfiguredAddresses($config), + GLPIMailer::validateAddress(...), + )); + } + + /** + * All the addresses of the configured list, including the invalid ones + * so that they stay visible in the config form. + * + * @return list + */ + public function getConfiguredAddresses(SimpleValueConfig $config): array + { + /** @var list $parts */ + $parts = preg_split('/[,;]/', $config->getValue()); + + $addresses = []; + foreach ($parts as $part) { + $email = trim($part); + if ($email !== '') { + $addresses[strtolower($email)] ??= $email; + } + } + + return array_values($addresses); + } + + /** + * The tags dropdown submits one value per address, they are stored as a + * list separated by commas. + * + * @param array $input + * @return array + */ + #[Override] + public function prepareInput(array $input): array + { + /** @var array $input */ + $input = parent::prepareInput($input); + + $field_input = $input[self::getKey()] ?? null; + if (!is_array($field_input) || !is_array($field_input[SimpleValueConfig::VALUE] ?? null)) { + return $input; + } + + $emails = array_filter( + array_map( + fn(mixed $email): string => is_string($email) ? trim($email) : '', + $field_input[SimpleValueConfig::VALUE], + ), + fn(string $email): bool => $email !== '', + ); + $field_input[SimpleValueConfig::VALUE] = implode(', ', $emails); + $input[self::getKey()] = $field_input; + + return $input; + } + + /** @param array $display_options */ + #[Override] + public function renderConfigForm( + Form $form, + FormDestination $destination, + JsonFieldInterface $config, + string $input_name, + array $display_options, + ): string { + if (!$config instanceof SimpleValueConfig) { + throw new InvalidArgumentException("Unexpected config class"); + } + + return TemplateRenderer::getInstance()->render('@advancedforms/destination/email_addresses_field.html.twig', [ + 'input_name' => $input_name . "[" . SimpleValueConfig::VALUE . "]", + 'addresses' => $this->getConfiguredAddresses($config), + 'label' => $this->getLabel(), + 'options' => $display_options, + ]); + } + + #[Override] + public function getWeight(): int + { + return 40; + } + + #[Override] + public function getCategory(): Category + { + return Category::ACTORS; + } +} diff --git a/src/Model/Destination/Email/EmailDestination.php b/src/Model/Destination/Email/EmailDestination.php new file mode 100644 index 0000000..dd5a0d6 --- /dev/null +++ b/src/Model/Destination/Email/EmailDestination.php @@ -0,0 +1,322 @@ + */ + public function getConfigurableFields(): array + { + return [ + new TitleField(), + new ContentField(), + new EmailRecipientsField(), + new EmailAddressesField(), + ]; + } + + #[Override] + public function getLabel(): string + { + return __('Email', 'advancedforms'); + } + + #[Override] + public function getIcon(): string + { + return 'ti ti-mail'; + } + + #[Override] + public function getWeight(): int + { + return 100; + } + + #[Override] + public function useDefaultConfigLayout(): bool + { + return false; + } + + /** @param array $config */ + #[Override] + public function createDestinationItems( + Form $form, + AnswersSet $answers_set, + array $config, + ): array { + if (ConfigManager::getInstance()->isConfigurableItemEnabled($this)) { + try { + (new EmailSender())->send($form, $answers_set, $config); + } catch (Throwable $e) { + // Destinations are created inside the transaction that saves + // the answers: an email must never cancel the submission. + ErrorHandler::logCaughtException($e); + } + } + + // Nothing is linked to the answers: an email is not an item the + // person who filled the form can open. + return []; + } + + #[Override] + public function postCreateDestinationItems( + Form $form, + AnswersSet $answers_set, + FormDestination $destination, + array $created_items, + ): void {} + + /** @param array $config */ + #[Override] + public function renderConfigForm( + Form $form, + FormDestination $destination, + array $config, + ): string { + $fields = []; + foreach ($this->getConfigurableFields() as $field) { + $fields[$field::getKey()] = $field; + } + + return TemplateRenderer::getInstance()->render( + '@advancedforms/destination/email_destination_config.html.twig', + [ + 'item' => $this, + 'form' => $form, + 'destination' => $destination, + 'config' => $config, + 'title_field' => $fields[TitleField::getKey()], + 'content_field' => $fields[ContentField::getKey()], + 'recipients_field' => $fields[EmailRecipientsField::getKey()], + 'addresses_field' => $fields[EmailAddressesField::getKey()], + 'can_update' => FormDestination::canUpdate() && $destination->canUpdateItem(), + 'recipients_warning' => $this->getRecipientsWarning($form, $config), + ], + ); + } + + /** + * Warn the form administrators when the email can be sent to an address + * typed in the answers: the form can then send any text to anyone. + * + * @param array $config + */ + public function getRecipientsWarning(Form $form, array $config): ?string + { + $recipients_field = new EmailRecipientsField(); + $recipients_config = $recipients_field->getConfig($form, $config); + if ( + !$recipients_config instanceof EmailRecipientsFieldConfig + || !$recipients_field->usesTypedEmailAddresses($recipients_config, $form) + ) { + return null; + } + + return FormAccessControlManager::getInstance()->allowUnauthenticatedAccess($form) + ? self::getUnauthenticatedRecipientsWarning() + : self::getTypedRecipientsWarning(); + } + + public static function getTypedRecipientsWarning(): string + { + return __('Anyone who can answer this form can have an email sent to any address.', 'advancedforms'); + } + + public static function getUnauthenticatedRecipientsWarning(): string + { + return __('Anyone can answer this form without being authenticated and have an email sent to any address.', 'advancedforms'); + } + + /** + * Name of the input of a config field, as expected by the core macro that + * renders the fields (see `AbstractCommonITILFormDestination::formatConfigInputName()`). + */ + public function formatConfigInputName(string $field_key): string + { + if (str_ends_with($field_key, '[]')) { + return "config[" . substr($field_key, 0, -2) . "][]"; + } + + return sprintf('config[%s]', $field_key); + } + + /** + * @param array $data + * @return array + */ + #[Override] + public function prepareInputForClone(array $data): array + { + foreach ($this->getConfigurableFields() as $field) { + $key = $field::getKey(); + if (!is_array($data[$key] ?? null)) { + continue; + } + + /** @var array $field_config */ + $field_config = $data[$key]; + $data[$key] = FormCloneHelper::getInstance()->prepareCommonItilDestinationFieldInputForClone( + $field, + $field_config, + ); + } + + return $data; + } + + /** @param array $config */ + #[Override] + public function exportDynamicConfig(array $config): DynamicExportDataField + { + $requirements = []; + foreach ($this->getConfigurableFields() as $field) { + $key = $field::getKey(); + if (!is_array($config[$key] ?? null)) { + continue; + } + + /** @var array $field_config */ + $field_config = $config[$key]; + $export_data = $field->exportDynamicConfig($field_config, self::getItilDestinationPlaceholder()); + $config[$key] = $export_data->getData(); + array_push($requirements, ...$export_data->getRequirements()); + } + + return new DynamicExportDataField($config, $requirements); + } + + /** + * @param array $config + * @return array + */ + #[Override] + public static function prepareDynamicConfigDataForImport( + array $config, + DatabaseMapper $mapper, + ): array { + $tags_manager = new FormTagsManager(); + foreach ((new self())->getConfigurableFields() as $field) { + $key = $field::getKey(); + if (!is_array($config[$key] ?? null)) { + continue; + } + + /** @var array $field_config */ + $field_config = $field::prepareDynamicConfigDataForImport( + $config[$key], + self::getItilDestinationPlaceholder(), + $mapper, + ); + + // Same as core: fields holding form tags refer to questions by id. + if ( + (new ReflectionClass($field))->getAttributes(HasFormTags::class) !== [] + && is_string($field_config[SimpleValueConfig::VALUE] ?? null) + ) { + $field_config[SimpleValueConfig::VALUE] = $tags_manager->replaceIdsInTags( + $field_config[SimpleValueConfig::VALUE], + $mapper, + ); + } + + $config[$key] = $field_config; + } + + return $config; + } + + /** + * The export and import methods of the core config fields are typed for + * ITIL destinations, although the fields used here never read that + * parameter. A ticket destination is given to satisfy the signature. + */ + private static function getItilDestinationPlaceholder(): FormDestinationTicket + { + return new FormDestinationTicket(); + } + + #[Override] + public static function getConfigKey(): string + { + return 'enable_destination_email'; + } + + #[Override] + public function getConfigTitle(): string + { + return __('Email destination', 'advancedforms'); + } + + #[Override] + public function getConfigDescription(): string + { + return __('Send a customized email, which may contain form tags, when the form is submitted.', 'advancedforms'); + } + + #[Override] + public function getConfigIcon(): string + { + return $this->getIcon(); + } +} diff --git a/src/Model/Destination/Email/EmailDestinationConfigNormalizer.php b/src/Model/Destination/Email/EmailDestinationConfigNormalizer.php new file mode 100644 index 0000000..bb44e82 --- /dev/null +++ b/src/Model/Destination/Email/EmailDestinationConfigNormalizer.php @@ -0,0 +1,71 @@ +input)) { + return; + } + + $itemtype = $item->input['itemtype'] ?? $item->fields['itemtype'] ?? null; + $config = $item->input['config'] ?? null; + if ( + $itemtype !== EmailDestination::class + || !is_array($config) + || ($item->input['_from_import'] ?? false) + ) { + return; + } + + foreach ((new EmailDestination())->getConfigurableFields() as $field) { + $config = $field->prepareInput($config); + } + + $item->input['config'] = $config; + } +} diff --git a/src/Model/Destination/Email/EmailRecipientsField.php b/src/Model/Destination/Email/EmailRecipientsField.php new file mode 100644 index 0000000..593b279 --- /dev/null +++ b/src/Model/Destination/Email/EmailRecipientsField.php @@ -0,0 +1,285 @@ +> */ + #[Override] + public function getAllowedActorTypes(): array + { + return [User::class, Group::class]; + } + + /** Used by the core to filter the groups offered as specific actors. */ + #[Override] + public function getActorType(): string + { + return 'observer'; + } + + #[Override] + public function getLabel(): string + { + return __('Recipients', 'advancedforms'); + } + + #[Override] + public function getConfigClass(): string + { + return EmailRecipientsFieldConfig::class; + } + + #[Override] + public function getDefaultConfig(Form $form): EmailRecipientsFieldConfig + { + return new EmailRecipientsFieldConfig([ITILActorFieldStrategy::SPECIFIC_VALUES]); + } + + /** @return array */ + #[Override] + public function getStrategiesForDropdown(): array + { + /** @var array $strategies */ + $strategies = parent::getStrategiesForDropdown(); + + // There is no template for an email + unset($strategies[ITILActorFieldStrategy::FROM_TEMPLATE->value]); + + return $strategies; + } + + #[Override] + public function getWeight(): int + { + return 30; + } + + /** + * Email addresses of the actors found by each configured strategy. + * + * @return list + */ + public function getEmailAddresses(EmailRecipientsFieldConfig $config, AnswersSet $answers_set): array + { + $recipients = []; + /** @var ITILActorFieldStrategy[] $strategies */ + $strategies = $config->getStrategies(); + foreach ($strategies as $strategy) { + /** @var array> $actors */ + $actors = $strategy->getITILActors($this, $config, $answers_set) ?? []; + foreach ($actors as $actor) { + array_push($recipients, ...$this->getActorRecipients($actor)); + } + } + + $users_emails = $this->getUsersDefaultEmails(array_filter($recipients, is_int(...))); + + $emails = []; + foreach ($recipients as $recipient) { + $email = is_int($recipient) ? $users_emails[$recipient] ?? null : $recipient; + if ($email !== null && GLPIMailer::validateAddress($email)) { + $emails[] = $email; + } + } + + return array_values(array_unique($emails)); + } + + /** + * Whether the recipients may include an address typed by the person who + * answers the form, from an "Email" question. + */ + public function usesTypedEmailAddresses(EmailRecipientsFieldConfig $config, Form $form): bool + { + $email_questions_ids = []; + foreach ($form->getQuestions() as $question) { + if ($question->getQuestionType() instanceof QuestionTypeEmail) { + $email_questions_ids[] = $question->getID(); + } + } + + /** @var int[] $specific_question_ids */ + $specific_question_ids = $config->getSpecificQuestionIds() ?? []; + /** @var ITILActorFieldStrategy[] $strategies */ + $strategies = $config->getStrategies(); + foreach ($strategies as $strategy) { + $uses_email_question = match ($strategy) { + ITILActorFieldStrategy::SPECIFIC_ANSWERS => array_intersect( + $specific_question_ids, + $email_questions_ids, + ) !== [], + ITILActorFieldStrategy::LAST_VALID_ANSWER => $email_questions_ids !== [], + default => false, + }; + if ($uses_email_question) { + return true; + } + } + + return false; + } + + /** + * Email address or user ids to notify for the actor. The notification + * settings of the actor are respected, like ITIL destinations do. + * + * @param array $actor + * @return list + */ + private function getActorRecipients(array $actor): array + { + if (isset($actor['use_notification']) && !$actor['use_notification']) { + return []; + } + + $alternative_email = $actor['alternative_email'] ?? ''; + if (is_string($alternative_email) && $alternative_email !== '') { + return [$alternative_email]; + } + + $items_id = $actor['items_id'] ?? 0; + if (!is_numeric($items_id)) { + return []; + } + + return match ($actor['itemtype'] ?? null) { + User::class => [(int) $items_id], + Group::class => $this->getGroupMembersIds((int) $items_id), + default => [], + }; + } + + /** + * Members of the group, unless the group must not be notified (same rule + * as GLPI notifications). + * + * @return list + */ + private function getGroupMembersIds(int $groups_id): array + { + $group = Group::getById($groups_id); + if (!$group instanceof Group || !$group->fields['is_notify']) { + return []; + } + + /** @var list $members */ + $members = Group_User::getGroupUsers($groups_id); + + return array_column($members, 'id'); + } + + /** + * Default email of each user, in a single query. The users GLPI + * notifications skip are left out: deleted, inactive or outside their + * validity dates. + * + * @param array $users_ids + * @return array email by user id + */ + private function getUsersDefaultEmails(array $users_ids): array + { + global $DB; + + if ($users_ids === []) { + return []; + } + + $emails_table = UserEmail::getTable(); + $users_table = User::getTable(); + $now = $_SESSION['glpi_currenttime']; + + $iterator = $DB->request([ + 'SELECT' => [$emails_table . '.users_id', $emails_table . '.email'], + 'FROM' => $emails_table, + 'INNER JOIN' => [ + $users_table => [ + 'FKEY' => [ + $users_table => 'id', + $emails_table => 'users_id', + ], + ], + ], + 'WHERE' => [ + $emails_table . '.users_id' => array_values(array_unique($users_ids)), + $emails_table . '.is_default' => 1, + $users_table . '.is_active' => 1, + $users_table . '.is_deleted' => 0, + ['OR' => [[$users_table . '.begin_date' => null], [$users_table . '.begin_date' => ['<=', $now]]]], + ['OR' => [[$users_table . '.end_date' => null], [$users_table . '.end_date' => ['>=', $now]]]], + ], + ]); + + /** @var array $emails */ + $emails = array_column(iterator_to_array($iterator, false), 'email', 'users_id'); + + return $emails; + } +} diff --git a/src/Model/Destination/Email/EmailRecipientsFieldConfig.php b/src/Model/Destination/Email/EmailRecipientsFieldConfig.php new file mode 100644 index 0000000..4d4e7ba --- /dev/null +++ b/src/Model/Destination/Email/EmailRecipientsFieldConfig.php @@ -0,0 +1,59 @@ + $data */ + #[Override] + public static function jsonDeserialize(array $data): self + { + /** @var array $specific_itilactors_ids */ + $specific_itilactors_ids = $data[self::SPECIFIC_ITILACTORS_IDS] ?? []; + /** @var array $specific_question_ids */ + $specific_question_ids = $data[self::SPECIFIC_QUESTION_IDS] ?? []; + + return new self( + strategies: self::deserializeStrategies($data, ITILActorFieldStrategy::SPECIFIC_VALUES), + specific_itilactors_ids: $specific_itilactors_ids, + specific_question_ids: $specific_question_ids, + ); + } +} diff --git a/src/Model/Destination/Email/EmailSender.php b/src/Model/Destination/Email/EmailSender.php new file mode 100644 index 0000000..a888ad4 --- /dev/null +++ b/src/Model/Destination/Email/EmailSender.php @@ -0,0 +1,160 @@ + $config */ + public function send(Form $form, AnswersSet $answers_set, array $config): void + { + global $CFG_GLPI; + + if (!$CFG_GLPI['use_notifications'] || !$CFG_GLPI['notifications_mailing']) { + return; + } + + $emails = $this->getEmailAddresses($form, $answers_set, $config); + if ($emails === []) { + $this->log($form, $answers_set, 'no valid recipient, nothing was sent.'); + return; + } + + $entities_id = $form->getEntityID(); + $sender = Config::getEmailSender($entities_id); + if ($sender['email'] === null) { + $this->log($form, $answers_set, 'no email sender is configured, nothing was sent.'); + return; + } + + $title_field = new TitleField(); + $subject = $title_field->applyConfiguratedValueToInputUsingAnswers( + $title_field->getConfig($form, $config, $answers_set), + [], + $answers_set, + )['name']; + + $content_field = new ContentField(); + $content = $content_field->applyConfiguratedValueToInputUsingAnswers( + $content_field->getConfig($form, $config, $answers_set), + [], + $answers_set, + )['content']; + $body_html = RichText::getSafeHtml(is_string($content) ? $content : ''); + $body_text = RichText::getTextFromHtml($body_html); + + $options = [ + '_itemtype' => AnswersSet::class, + '_items_id' => $answers_set->getID(), + '_notificationtemplates_id' => 0, + '_entities_id' => $entities_id, + 'from' => $sender['email'], + 'fromname' => $sender['name'], + 'subject' => $subject, + 'content_text' => $body_text, + 'content_html' => $body_html, + ]; + + $reply_to = Config::getReplyToEmailSender($entities_id); + if ($reply_to['email'] !== null) { + $options['replyto'] = $reply_to['email']; + $options['replytoname'] = $reply_to['name']; + } + + $mailing = new NotificationMailing(); + foreach ($emails as $email) { + if (!$mailing->sendNotification($options + ['to' => $email, 'toname' => ''])) { + $this->log($form, $answers_set, sprintf('unable to queue the email to %s.', $email)); + } + } + } + + /** + * Configured recipients and additional addresses, without duplicates + * (case insensitive, first occurrence kept). + * + * @param array $config + * @return list + */ + private function getEmailAddresses(Form $form, AnswersSet $answers_set, array $config): array + { + $recipients_field = new EmailRecipientsField(); + $recipients_config = $recipients_field->getConfig($form, $config, $answers_set); + + $addresses_field = new EmailAddressesField(); + $addresses_config = $addresses_field->getConfig($form, $config, $answers_set); + + $emails = [ + ...($recipients_config instanceof EmailRecipientsFieldConfig + ? $recipients_field->getEmailAddresses($recipients_config, $answers_set) + : []), + ...($addresses_config instanceof SimpleValueConfig + ? $addresses_field->getEmailAddresses($addresses_config) + : []), + ]; + + $unique = []; + foreach ($emails as $email) { + $unique[strtolower($email)] ??= $email; + } + + return array_values($unique); + } + + private function log(Form $form, AnswersSet $answers_set, string $message): void + { + Toolbox::logInFile('advancedforms', sprintf( + "Email destination of form \"%s\" (answers #%d): %s\n", + $form->getName(), + $answers_set->getID(), + $message, + )); + } +} diff --git a/src/Service/ConfigManager.php b/src/Service/ConfigManager.php index 42470da..3446739 100644 --- a/src/Service/ConfigManager.php +++ b/src/Service/ConfigManager.php @@ -35,9 +35,11 @@ use Config; use Glpi\Application\View\TemplateRenderer; +use Glpi\Form\Destination\FormDestinationInterface; use Glpi\Form\QuestionType\QuestionTypeInterface; use Glpi\Toolbox\SingletonTrait; use GlpiPlugin\Advancedforms\Model\Config\ConfigurableItemInterface; +use GlpiPlugin\Advancedforms\Model\Destination\Email\EmailDestination; use GlpiPlugin\Advancedforms\Model\QuestionType\HiddenQuestion; use GlpiPlugin\Advancedforms\Model\QuestionType\HostnameQuestion; use GlpiPlugin\Advancedforms\Model\QuestionType\IpAddressQuestion; @@ -56,6 +58,7 @@ public function renderConfigForm(): string return $twig->render('@advancedforms/config_form.html.twig', [ 'config_manager' => $this, 'question_types' => $this->getConfigurableQuestionTypes(), + 'destinations' => $this->getConfigurableDestinations(), ]); } @@ -101,4 +104,21 @@ public function hasAtLeastOneQuestionTypeEnabled(): bool { return $this->getEnabledQuestionsTypes() !== []; } + + /** @return array */ + public function getConfigurableDestinations(): array + { + return [ + new EmailDestination(), + ]; + } + + /** @return array */ + public function getEnabledDestinations(): array + { + return array_filter( + $this->getConfigurableDestinations(), + $this->isConfigurableItemEnabled(...), + ); + } } diff --git a/src/Service/InitManager.php b/src/Service/InitManager.php index d657c10..f146703 100644 --- a/src/Service/InitManager.php +++ b/src/Service/InitManager.php @@ -34,6 +34,7 @@ namespace GlpiPlugin\Advancedforms\Service; use Config; +use Glpi\Form\Destination\FormDestination; use Glpi\Form\Destination\FormDestinationManager; use Glpi\Form\Destination\FormDestinationTicket; use Glpi\Form\Migration\TypesConversionMapper; @@ -41,6 +42,7 @@ use Glpi\Plugin\Hooks; use Glpi\Toolbox\SingletonTrait; use GlpiPlugin\Advancedforms\Model\Config\ConfigTab; +use GlpiPlugin\Advancedforms\Model\Destination\Email\EmailDestinationConfigNormalizer; use GlpiPlugin\Advancedforms\Model\Destination\PreReservationField; use GlpiPlugin\Advancedforms\Model\QuestionType\AdvancedCategory; use GlpiPlugin\Advancedforms\Model\QuestionType\LegacyQuestionTypeInterface; @@ -57,9 +59,11 @@ public function init(): void $this->registerConfiguration(); $this->registerPluginTypes(); $this->registerDestinationFields(); + $this->registerDestinationTypes(); $this->registerTimelineHooks(); $this->registerNotifications(); $this->registerPurgeHooks(); + $this->registerDestinationHooks(); } private function registerNotifications(): void @@ -140,6 +144,25 @@ private function registerDestinationFields(): void ); } + private function registerDestinationTypes(): void + { + $destination_manager = FormDestinationManager::getInstance(); + + // init() may run multiple times per process; avoid double-registering a type. + $registered_classes = array_map( + get_class(...), + $destination_manager->getDestinationTypes(), + ); + + foreach (ConfigManager::getInstance()->getEnabledDestinations() as $destination) { + if (in_array($destination::class, $registered_classes, true)) { + continue; + } + + $destination_manager->registerPluginDestinationType($destination); + } + } + private function registerTimelineHooks(): void { global $PLUGIN_HOOKS; @@ -158,4 +181,22 @@ private function registerPurgeHooks(): void Ticket::class => TicketReservationRequest::purgeForTicket(...), ]; } + + /** + * Registered even when the email destination is disabled: existing email + * destinations stay editable. + */ + private function registerDestinationHooks(): void + { + global $PLUGIN_HOOKS; + + // @phpstan-ignore offsetAccess.nonOffsetAccessible (we don't have type hint for this array at this time) + $PLUGIN_HOOKS[Hooks::PRE_ITEM_ADD]['advancedforms'] = [ + FormDestination::class => EmailDestinationConfigNormalizer::onPreSave(...), + ]; + // @phpstan-ignore offsetAccess.nonOffsetAccessible (we don't have type hint for this array at this time) + $PLUGIN_HOOKS[Hooks::PRE_ITEM_UPDATE]['advancedforms'] = [ + FormDestination::class => EmailDestinationConfigNormalizer::onPreSave(...), + ]; + } } diff --git a/templates/config_form.html.twig b/templates/config_form.html.twig index a6ce18d..dccff3d 100644 --- a/templates/config_form.html.twig +++ b/templates/config_form.html.twig @@ -52,6 +52,16 @@ {% endfor %} +

{{ __("Destinations", "advancedforms") }}

+
+ {% for destination in destinations %} + {{ include('@advancedforms/configurable_item.html.twig', { + config_manager: config_manager, + item: destination, + }, with_context: false) }} + {% endfor %} +
+ diff --git a/templates/destination/email_addresses_field.html.twig b/templates/destination/email_addresses_field.html.twig new file mode 100644 index 0000000..401e3ae --- /dev/null +++ b/templates/destination/email_addresses_field.html.twig @@ -0,0 +1,75 @@ +{# + # ------------------------------------------------------------------------- + # advancedforms plugin for GLPI + # ------------------------------------------------------------------------- + # + # MIT License + # + # Permission is hereby granted, free of charge, to any person obtaining a copy + # of this software and associated documentation files (the "Software"), to deal + # in the Software without restriction, including without limitation the rights + # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + # copies of the Software, and to permit persons to whom the Software is + # furnished to do so, subject to the following conditions: + # + # The above copyright notice and this permission notice shall be included in all + # copies or substantial portions of the Software. + # + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + # SOFTWARE. + # ------------------------------------------------------------------------- + # @copyright Copyright (C) 2025 by the advancedforms plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/advancedforms + # ------------------------------------------------------------------------- + #} + +{# @var string input_name #} +{# @var list addresses #} +{# @var string label #} +{# @var array options #} + +{# Same id as the label of the core field section points to #} +{% set field_id = input_name ~ '_' ~ (options.rand ?? random()) %} + + + + diff --git a/templates/destination/email_destination_config.html.twig b/templates/destination/email_destination_config.html.twig new file mode 100644 index 0000000..406dd3c --- /dev/null +++ b/templates/destination/email_destination_config.html.twig @@ -0,0 +1,77 @@ +{# + # ------------------------------------------------------------------------- + # advancedforms plugin for GLPI + # ------------------------------------------------------------------------- + # + # MIT License + # + # Permission is hereby granted, free of charge, to any person obtaining a copy + # of this software and associated documentation files (the "Software"), to deal + # in the Software without restriction, including without limitation the rights + # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + # copies of the Software, and to permit persons to whom the Software is + # furnished to do so, subject to the following conditions: + # + # The above copyright notice and this permission notice shall be included in all + # copies or substantial portions of the Software. + # + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + # SOFTWARE. + # ------------------------------------------------------------------------- + # @copyright Copyright (C) 2025 by the advancedforms plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/advancedforms + # ------------------------------------------------------------------------- + #} + + +{# @var GlpiPlugin\Advancedforms\Model\Destination\Email\EmailDestination item #} +{# @var Glpi\Form\Form form #} +{# @var Glpi\Form\Destination\FormDestination destination #} +{# @var array config #} +{# @var bool can_update #} +{# @var string|null recipients_warning #} + +{# The fields are rendered by the same macro as the core ITIL destinations #} +{% import 'pages/admin/form/form_destination_commonitil_config.html.twig' as itil_config %} + +
+
+ {# Form rows: label above the field, hairline between the fields #} +
+ {{ __('Subject', 'advancedforms') }} + {{ itil_config.field_config_section(item, destination, title_field, form, config, false) }} +
+ {# The label shares the line of the "Auto config" switch of the core field #} +
+ {{ __('Body', 'advancedforms') }} + {{ itil_config.field_config_section(item, destination, content_field, form, config, false) }} +
+
+ + +
diff --git a/tests/AdvancedFormsTestCase.php b/tests/AdvancedFormsTestCase.php index e259224..21e54f9 100644 --- a/tests/AdvancedFormsTestCase.php +++ b/tests/AdvancedFormsTestCase.php @@ -34,6 +34,7 @@ namespace GlpiPlugin\Advancedforms\Tests; use AuthLDAP; +use Glpi\Form\Destination\FormDestinationInterface; use Glpi\Form\Form; use Glpi\Form\Migration\TypesConversionMapper; use Glpi\Form\QuestionType\QuestionTypeInterface; @@ -58,6 +59,13 @@ final public static function provideQuestionTypes(): array return array_map(fn($c): array => [$c], $types); } + /** @return array */ + final public static function provideDestinations(): array + { + $destinations = ConfigManager::getInstance()->getConfigurableDestinations(); + return array_map(fn($d): array => [$d], $destinations); + } + public function setUp(): void { parent::setUp(); diff --git a/tests/ConfigurableItemsTrait.php b/tests/ConfigurableItemsTrait.php index 935430d..30a5cf8 100644 --- a/tests/ConfigurableItemsTrait.php +++ b/tests/ConfigurableItemsTrait.php @@ -101,7 +101,7 @@ private function setConfigurableItemConfig( } /** @var array $classes */ - private function deleteSingletonInstance(array $classes) + protected function deleteSingletonInstance(array $classes) { foreach ($classes as $class) { $reflection_class = new ReflectionClass($class); diff --git a/tests/Front/ConfigFormTest.php b/tests/Front/ConfigFormTest.php index 004a7d0..98b7f91 100644 --- a/tests/Front/ConfigFormTest.php +++ b/tests/Front/ConfigFormTest.php @@ -88,7 +88,8 @@ public function testTabContentIsEmptyForTech(): void } #[DataProvider('provideQuestionTypes')] - public function testCanDisableQuestionTypeConfig( + #[DataProvider('provideDestinations')] + public function testCanDisableConfig( ConfigurableItemInterface $item, ): void { // Arrange: enable config @@ -106,7 +107,8 @@ public function testCanDisableQuestionTypeConfig( } #[DataProvider('provideQuestionTypes')] - public function testCanEnableQuestionTypeConfig( + #[DataProvider('provideDestinations')] + public function testCanEnableConfig( ConfigurableItemInterface $item, ): void { // Arrange: disable config diff --git a/tests/Model/Destination/Email/EmailDestinationConfigTest.php b/tests/Model/Destination/Email/EmailDestinationConfigTest.php new file mode 100644 index 0000000..5942676 --- /dev/null +++ b/tests/Model/Destination/Email/EmailDestinationConfigTest.php @@ -0,0 +1,95 @@ +deleteSingletonInstance([FormDestinationManager::class]); + + // Act: init the plugin with the destination disabled + $this->disableConfigurableItem(new EmailDestination()); + + // Assert: the destination type is not available + $this->assertCount(0, $this->getRegisteredEmailDestinations()); + } + + public function testDestinationIsRegisteredOnceWhenEnabled(): void + { + // Arrange: start from a fresh destination manager + $this->deleteSingletonInstance([FormDestinationManager::class]); + + // Act: init the plugin twice with the destination enabled + $this->enableConfigurableItem(new EmailDestination()); + InitManager::getInstance()->init(); + + // Assert: the destination type is available once + $this->assertCount(1, $this->getRegisteredEmailDestinations()); + $values = FormDestinationManager::getInstance()->getDestinationTypesDropdownValues(); + $this->assertSame('Email', $values[EmailDestination::class]); + } + + public function testConfigurableDestinations(): void + { + $manager = ConfigManager::getInstance(); + + $destinations = $manager->getConfigurableDestinations(); + $this->assertCount(1, $destinations); + $this->assertInstanceOf(EmailDestination::class, current($destinations)); + + $this->disableConfigurableItem(new EmailDestination()); + $this->assertCount(0, $manager->getEnabledDestinations()); + + $this->enableConfigurableItem(new EmailDestination()); + $this->assertCount(1, $manager->getEnabledDestinations()); + } + + /** @return EmailDestination[] */ + private function getRegisteredEmailDestinations(): array + { + return array_filter( + FormDestinationManager::getInstance()->getDestinationTypes(), + fn(FormDestinationInterface $type): bool => $type instanceof EmailDestination, + ); + } +} diff --git a/tests/Model/Destination/Email/EmailDestinationExportImportTest.php b/tests/Model/Destination/Email/EmailDestinationExportImportTest.php new file mode 100644 index 0000000..21dd589 --- /dev/null +++ b/tests/Model/Destination/Email/EmailDestinationExportImportTest.php @@ -0,0 +1,292 @@ +createActors(); + $form = $this->createEmailForm(); + $this->updateEmailDestinationConfig($form, [ + EmailRecipientsField::getKey() => $this->getSpecificActorsInput($user, $group), + ]); + + // Act + $imported = $this->exportAndImport($form); + + // Assert + $config = $this->getEmailConfig($imported)[EmailRecipientsField::getKey()]; + $this->assertSame( + [User::class => [$user->getID()], Group::class => [$group->getID()]], + $config['specific_itilactors_ids'], + ); + } + + public function testExportUsesNamesForActors(): void + { + [$user, $group] = $this->createActors(); + $form = $this->createEmailForm(); + $this->updateEmailDestinationConfig($form, [ + EmailRecipientsField::getKey() => $this->getSpecificActorsInput($user, $group), + ]); + + $json = $this->export($form); + + $data = json_decode($json, true); + $destination = $this->findEmailDestinationSpec($data); + $this->assertSame( + [User::class => ['Exported user'], Group::class => ['Exported group']], + $destination['config'][EmailRecipientsField::getKey()]['specific_itilactors_ids'], + ); + $requirements = array_column($data['forms'][0]['data_requirements'], 'name', 'itemtype'); + $this->assertSame('Exported group', $requirements[Group::class]); + $this->assertSame('Exported user', $requirements[User::class]); + } + + public function testExportDropsDeletedActors(): void + { + [$user, $group] = $this->createActors(); + $form = $this->createEmailForm(); + $this->updateEmailDestinationConfig($form, [ + EmailRecipientsField::getKey() => $this->getSpecificActorsInput($user, $group), + ]); + $this->assertTrue($user->delete(['id' => $user->getID()], force: true)); + + $data = json_decode($this->export($form), true); + + $destination = $this->findEmailDestinationSpec($data); + $this->assertSame( + [User::class => [], Group::class => ['Exported group']], + $destination['config'][EmailRecipientsField::getKey()]['specific_itilactors_ids'], + ); + } + + public function testExportImportRemapsQuestionsAndTags(): void + { + // Arrange: recipients and tags referencing the form questions + $form = $this->createEmailForm(); + $this->updateEmailDestinationConfig($form, [ + TitleField::getKey() => ['value' => 'Hello ' . $this->getAnswerTag($form, 'Name')], + ContentField::getKey() => ['value' => '

' . $this->getAnswerTag($form, 'Name') . '

'], + ContentField::getAutoConfigKey() => 0, + EmailRecipientsField::getKey() => $this->getSpecificAnswersInput($form), + ]); + + // Act + $imported = $this->exportAndImport($form); + + // Assert: references point to the imported form questions + $this->assertReferencesQuestionsOf($imported); + } + + public function testExportImportKeepsFreeAddressesAndAutoFlag(): void + { + $form = $this->createEmailForm(); + $this->updateEmailDestinationConfig($form, [ + EmailAddressesField::getKey() => ['value' => 'hr@example.com; it@example.com'], + ContentField::getAutoConfigKey() => 0, + ]); + + $config = $this->getEmailConfig($this->exportAndImport($form)); + + $this->assertSame('hr@example.com; it@example.com', $config[EmailAddressesField::getKey()]['value']); + $this->assertEquals(0, $config[ContentField::getAutoConfigKey()]); + } + + public function testCloneRemapsQuestionsAndTags(): void + { + $form = $this->createEmailForm(); + $this->updateEmailDestinationConfig($form, [ + TitleField::getKey() => ['value' => 'Hello ' . $this->getAnswerTag($form, 'Name')], + ContentField::getKey() => ['value' => '

' . $this->getAnswerTag($form, 'Name') . '

'], + ContentField::getAutoConfigKey() => 0, + EmailRecipientsField::getKey() => $this->getSpecificAnswersInput($form), + ]); + + $clone_id = $form->clone(); + $this->assertIsInt($clone_id); + + $this->assertReferencesQuestionsOf(Form::getById($clone_id)); + } + + public function testImportedFormSendsEmail(): void + { + global $CFG_GLPI; + + // Arrange: the core test case restores the config after the test + $CFG_GLPI['use_notifications'] = 1; + $CFG_GLPI['notifications_mailing'] = 1; + $CFG_GLPI['from_email'] = 'forms@example.com'; + $this->enableConfigurableItem(new EmailDestination()); + + [$user, $group] = $this->createActors(); + $form = $this->createEmailForm(); + $this->updateEmailDestinationConfig($form, [ + EmailRecipientsField::getKey() => $this->getSpecificActorsInput($user, $group), + EmailAddressesField::getKey() => ['value' => 'hr@example.com'], + ]); + $imported = $this->exportAndImport($form); + + // Act + $answers_set = $this->sendFormAndGetAnswerSet($imported, ['Name' => 'Jean']); + + // Assert + $this->assertSame( + ['user@example.com', 'member@example.com', 'hr@example.com'], + array_column($this->getQueuedEmails($answers_set), 'recipient'), + ); + } + + private function assertReferencesQuestionsOf(Form $form): void + { + $name_id = $this->getQuestionId($form, 'Name'); + $email_id = $this->getQuestionId($form, 'Email'); + $config = $this->getEmailConfig($form); + + $this->assertSame( + [$email_id], + array_map(intval(...), $config[EmailRecipientsField::getKey()]['specific_question_ids']), + ); + $this->assertStringContainsString( + 'data-form-tag-value="' . $name_id . '"', + $config[TitleField::getKey()]['value'], + ); + $this->assertStringContainsString( + 'data-form-tag-value="' . $name_id . '"', + $config[ContentField::getKey()]['value'], + ); + } + + /** @return array{User, Group} */ + private function createActors(): array + { + $user = $this->createUserWithEmail('Exported user', 'user@example.com'); + $group = $this->createItem(Group::class, [ + 'name' => 'Exported group', + 'entities_id' => $this->getTestRootEntity(only_id: true), + ]); + $this->addUserToGroup($group, $this->createUserWithEmail('Exported member', 'member@example.com')); + + return [$user, $group]; + } + + private function createEmailForm(): Form + { + $this->login(); + $builder = new FormBuilder("Exported email form"); + $builder->setShouldInitDestinations(false); + $builder->addQuestion("Name", QuestionTypeShortText::class); + $builder->addQuestion("Email", QuestionTypeEmail::class); + $builder->addDestination(EmailDestination::class, 'Email'); + + return $this->createForm($builder); + } + + /** @return array */ + private function getEmailConfig(Form $form): array + { + return FormDestination::getById($this->getDestinationId($form, 'Email'))->getConfig(); + } + + /** @return array */ + private function getSpecificAnswersInput(Form $form): array + { + return (new EmailRecipientsFieldConfig( + strategies: [ITILActorFieldStrategy::SPECIFIC_ANSWERS], + specific_question_ids: [$this->getQuestionId($form, 'Email')], + ))->jsonSerialize(); + } + + private function export(Form $form): string + { + return (new FormSerializer())->exportFormsToJson([$form])->getJsonContent(); + } + + private function exportAndImport(Form $form): Form + { + // Same mapper as the core FormTesterTrait::exportAndImportForm() + $active_entities = Session::getActiveEntities(); + $mapper = new DatabaseMapper( + $active_entities !== [] ? $active_entities : [$this->getTestRootEntity(only_id: true)], + ); + $result = (new FormSerializer())->importFormsFromJson($this->export($form), $mapper); + $forms = $result->getImportedForms(); + $this->assertCount(1, $forms, print_r($result->getFailedFormImports(), true)); + + $imported = current($forms); + $this->assertNotSame($form->getID(), $imported->getID()); + + return $imported; + } + + /** + * @param array $data + * @return array + */ + private function findEmailDestinationSpec(array $data): array + { + foreach ($data['forms'][0]['destinations'] as $destination) { + if ($destination['itemtype'] === EmailDestination::class) { + return $destination; + } + } + + $this->fail('Email destination not found in export'); + } +} diff --git a/tests/Model/Destination/Email/EmailDestinationFieldsTest.php b/tests/Model/Destination/Email/EmailDestinationFieldsTest.php new file mode 100644 index 0000000..5878b4d --- /dev/null +++ b/tests/Model/Destination/Email/EmailDestinationFieldsTest.php @@ -0,0 +1,232 @@ +createItem(User::class, ['name' => 'Email recipient']); + $group = $this->createItem(Group::class, ['name' => 'Email recipients']); + + // Act: create an email destination with these actors + $destination = $this->createEmailDestination([ + EmailRecipientsField::getKey() => $this->getSpecificActorsInput($user, $group), + ]); + + // Assert: actors are stored by itemtype + $config = $destination->getConfig()[EmailRecipientsField::getKey()]; + $this->assertSame( + [User::class => [$user->getID()], Group::class => [$group->getID()]], + $config['specific_itilactors_ids'], + ); + } + + public function testAddressesSelectedInTheDropdownAreJoinedOnSave(): void + { + // The addresses dropdown submits one value per address + $destination = $this->createEmailDestination([ + EmailAddressesField::getKey() => ['value' => ['a@example.com', ' b@example.com ', '']], + ]); + + $this->assertSame( + 'a@example.com, b@example.com', + $destination->getConfig()[EmailAddressesField::getKey()]['value'], + ); + } + + public function testAddressesSubmittedAsTextAreKeptOnSave(): void + { + $destination = $this->createEmailDestination([ + EmailAddressesField::getKey() => ['value' => 'a@example.com; b@example.com'], + ]); + + $this->assertSame( + 'a@example.com; b@example.com', + $destination->getConfig()[EmailAddressesField::getKey()]['value'], + ); + } + + public function testSubjectHtmlIsStrippedOnSave(): void + { + $destination = $this->createEmailDestination([ + TitleField::getKey() => ['value' => '

Hello x

'], + ]); + + $this->assertSame( + 'Hello x', + $destination->getConfig()[TitleField::getKey()]['value'], + ); + } + + public function testNormalizationRunsOnUpdate(): void + { + // Arrange: an email destination without specific actors + $user = $this->createItem(User::class, ['name' => 'Email recipient']); + $group = $this->createItem(Group::class, ['name' => 'Email recipients']); + $destination = $this->createEmailDestination([]); + + // Act: select actors as the actors dropdown submits them + $this->updateItem(FormDestination::class, $destination->getID(), [ + 'config' => [ + EmailRecipientsField::getKey() => $this->getSpecificActorsInput($user, $group), + ], + ], ['config']); + + // Assert: actors are stored by itemtype + $destination->getFromDB($destination->getID()); + $config = $destination->getConfig()[EmailRecipientsField::getKey()]; + $this->assertSame( + [User::class => [$user->getID()], Group::class => [$group->getID()]], + $config['specific_itilactors_ids'], + ); + } + + public function testImportedConfigIsNotNormalized(): void + { + // Arrange: config in its stored format, as found in an export + $user = $this->createItem(User::class, ['name' => 'Email recipient']); + $stored = (new EmailRecipientsFieldConfig( + strategies: [ITILActorFieldStrategy::SPECIFIC_VALUES], + specific_itilactors_ids: [User::class => [$user->getID()]], + ))->jsonSerialize(); + + // Act: create the destination as the importer does + $destination = $this->createItem(FormDestination::class, [ + Form::getForeignKeyField() => $this->createForm(new FormBuilder("Email form"))->getID(), + 'itemtype' => EmailDestination::class, + 'name' => 'Email', + 'config' => [EmailRecipientsField::getKey() => $stored], + '_from_import' => true, + ], ['config']); + + // Assert: config is kept as is + $this->assertSame( + [User::class => [$user->getID()]], + $destination->getConfig()[EmailRecipientsField::getKey()]['specific_itilactors_ids'], + ); + } + + public function testOtherDestinationTypesAreUntouched(): void + { + // Arrange: a ticket destination holding a value under the recipients key + $user = $this->createItem(User::class, ['name' => 'Email recipient']); + $raw = ['strategies' => ['specific_values'], 'specific_itilactors_ids' => ['users_id-' . $user->getID()]]; + + // Act + $builder = new FormBuilder("Ticket form"); + $builder->addDestination(FormDestinationTicket::class, 'Ticket with email key', [ + EmailRecipientsField::getKey() => $raw, + ]); + $form = $this->createForm($builder); + + // Assert: the plugin hook did not normalize it + $destination = FormDestination::getById($this->getDestinationId($form, 'Ticket with email key')); + $this->assertSame($raw, $destination->getConfig()[EmailRecipientsField::getKey()]); + } + + public function testStrategiesOffered(): void + { + $strategies = array_keys((new EmailRecipientsField())->getStrategiesForDropdown()); + + $this->assertNotContains(ITILActorFieldStrategy::FROM_TEMPLATE->value, $strategies); + $this->assertContains(ITILActorFieldStrategy::SPECIFIC_VALUES->value, $strategies); + $this->assertContains(ITILActorFieldStrategy::SPECIFIC_ANSWERS->value, $strategies); + $this->assertContains(ITILActorFieldStrategy::FORM_FILLER->value, $strategies); + $this->assertContains(ITILActorFieldStrategy::FORM_FILLER_SUPERVISOR->value, $strategies); + } + + public function testDefaultRecipientsConfig(): void + { + $form = $this->createForm(new FormBuilder("Email form")); + + $config = (new EmailRecipientsField())->getConfig($form, []); + + $this->assertInstanceOf(EmailRecipientsFieldConfig::class, $config); + $this->assertSame([ITILActorFieldStrategy::SPECIFIC_VALUES], $config->getStrategies()); + $this->assertEmpty($config->getSpecificITILActorsIds()); + } + + public function testFormatConfigInputName(): void + { + $destination = new EmailDestination(); + + $this->assertSame('config[foo]', $destination->formatConfigInputName('foo')); + $this->assertSame('config[foo][]', $destination->formatConfigInputName('foo[]')); + } + + public function testDestinationFields(): void + { + $keys = array_map( + fn($field) => $field::getKey(), + (new EmailDestination())->getConfigurableFields(), + ); + + // The stored config uses these keys: they must not change + $this->assertSame([ + 'glpi-form-destination-commonitilfield-titlefield', + 'glpi-form-destination-commonitilfield-contentfield', + 'glpiplugin-advancedforms-model-destination-email-emailrecipientsfield', + 'glpiplugin-advancedforms-model-destination-email-emailaddressesfield', + ], $keys); + } + + /** @param array $config */ + private function createEmailDestination(array $config): FormDestination + { + $builder = new FormBuilder("Email form"); + $builder->addDestination(EmailDestination::class, 'Email', $config); + + $form = $this->createForm($builder); + + return FormDestination::getById($this->getDestinationId($form, 'Email')); + } +} diff --git a/tests/Model/Destination/Email/EmailDestinationTestTrait.php b/tests/Model/Destination/Email/EmailDestinationTestTrait.php new file mode 100644 index 0000000..68e26d9 --- /dev/null +++ b/tests/Model/Destination/Email/EmailDestinationTestTrait.php @@ -0,0 +1,116 @@ +createItem(User::class, ['name' => $name, 'is_active' => (int) $is_active]); + $this->createItem(UserEmail::class, [ + 'users_id' => $user->getID(), + 'email' => $email, + 'is_default' => 1, + ]); + + return $user; + } + + private function addUserToGroup(Group $group, User $user): void + { + $this->createItem(Group_User::class, [ + 'groups_id' => $group->getID(), + 'users_id' => $user->getID(), + ]); + } + + /** + * Recipients config selecting the given users and groups, as the actors + * dropdown submits them. + * + * @return array + */ + private function getSpecificActorsInput(CommonDBTM ...$actors): array + { + return (new EmailRecipientsFieldConfig( + strategies: [ITILActorFieldStrategy::SPECIFIC_VALUES], + specific_itilactors_ids: array_map( + fn(CommonDBTM $actor): string => $actor::getForeignKeyField() . '-' . $actor->getID(), + $actors, + ), + ))->jsonSerialize(); + } + + /** @param array $config */ + private function updateEmailDestinationConfig(Form $form, array $config): void + { + $id = $this->getDestinationId($form, 'Email'); + $this->updateItem(FormDestination::class, $id, [ + 'config' => array_merge(FormDestination::getById($id)->getConfig(), $config), + ], ['config']); + } + + private function getAnswerTag(Form $form, string $question_name): string + { + $question = Question::getById($this->getQuestionId($form, $question_name)); + + return (new AnswerTagProvider())->getTagForQuestion($question)->html; + } + + /** @return array> */ + private function getQueuedEmails(AnswersSet $answers_set): array + { + return array_values((new QueuedNotification())->find([ + 'itemtype' => AnswersSet::class, + 'items_id' => $answers_set->getID(), + ], ['id ASC'])); + } +} diff --git a/tests/Model/Destination/Email/EmailRecipientsResolutionTest.php b/tests/Model/Destination/Email/EmailRecipientsResolutionTest.php new file mode 100644 index 0000000..51eae67 --- /dev/null +++ b/tests/Model/Destination/Email/EmailRecipientsResolutionTest.php @@ -0,0 +1,431 @@ +createUserWithEmail('Recipient', 'recipient@example.com'); + + $emails = $this->resolve( + [ITILActorFieldStrategy::SPECIFIC_VALUES], + specific_itilactors_ids: [User::class => [$user->getID()]], + ); + + $this->assertSame(['recipient@example.com'], $emails); + } + + public function testSpecificGroupMembers(): void + { + // Arrange: a group with two active members, an inactive one and one without email + $group = $this->createItem(Group::class, ['name' => 'Payroll']); + $members = [ + $this->createUserWithEmail('Member 1', 'member1@example.com'), + $this->createUserWithEmail('Member 2', 'member2@example.com'), + $this->createUserWithEmail('Inactive member', 'inactive@example.com', is_active: false), + $this->createItem(User::class, ['name' => 'Member without email']), + ]; + foreach ($members as $member) { + $this->addUserToGroup($group, $member); + } + + // Act + $emails = $this->resolve( + [ITILActorFieldStrategy::SPECIFIC_VALUES], + specific_itilactors_ids: [Group::class => [$group->getID()]], + ); + + // Assert: only active members with an email + $this->assertSame(['member1@example.com', 'member2@example.com'], $emails); + } + + public function testDefaultEmailOfEachUser(): void + { + // Arrange: users with several emails, the default one is not the + // first one (adding a default email unsets the previous default) + $group = $this->createItem(Group::class, ['name' => 'Several emails']); + $user = $this->createUserWithEmail('Several emails', 'old-default@example.com'); + $member = $this->createUserWithEmail('Member', 'member-old@example.com'); + $this->addUserToGroup($group, $member); + foreach ([$user, $member] as $item) { + $this->createItem(UserEmail::class, [ + 'users_id' => $item->getID(), + 'email' => $item->fields['name'] === 'Member' ? 'member@example.com' : 'default@example.com', + 'is_default' => 1, + ]); + } + + // Act + $emails = $this->resolve( + [ITILActorFieldStrategy::SPECIFIC_VALUES], + specific_itilactors_ids: [ + User::class => [$user->getID()], + Group::class => [$group->getID()], + ], + ); + + // Assert: only the default email of each user + $this->assertSame(['default@example.com', 'member@example.com'], $emails); + } + + public function testUsersOutsideTheirValidityDatesAreIgnored(): void + { + // Arrange: an expired account and an account not yet valid, like GLPI notifications skip + $expired = $this->createUserWithEmail('Expired', 'expired@example.com'); + $this->updateItem(User::class, $expired->getID(), ['end_date' => '2000-01-01 00:00:00']); + $future = $this->createUserWithEmail('Future', 'future@example.com'); + $this->updateItem(User::class, $future->getID(), ['begin_date' => '2037-01-01 00:00:00']); + $valid = $this->createUserWithEmail('Valid', 'valid@example.com'); + + // Act + $emails = $this->resolve( + [ITILActorFieldStrategy::SPECIFIC_VALUES], + specific_itilactors_ids: [User::class => [$expired->getID(), $future->getID(), $valid->getID()]], + ); + + // Assert + $this->assertSame(['valid@example.com'], $emails); + } + + public function testGroupWithoutNotificationsIsIgnored(): void + { + // Arrange: a group that must not be notified, like GLPI notifications skip + $group = $this->createItem(Group::class, ['name' => 'Not notified', 'is_notify' => 0]); + $member = $this->createUserWithEmail('Member', 'member@example.com'); + $this->addUserToGroup($group, $member); + + // Act + $emails = $this->resolve( + [ITILActorFieldStrategy::SPECIFIC_VALUES], + specific_itilactors_ids: [Group::class => [$group->getID()]], + ); + + // Assert + $this->assertSame([], $emails); + } + + public function testFormFiller(): void + { + $this->addEmailToCurrentUser('filler@example.com'); + + $emails = $this->resolve([ITILActorFieldStrategy::FORM_FILLER]); + + $this->assertSame(['filler@example.com'], $emails); + } + + public function testFormFillerSupervisor(): void + { + $this->addEmailToCurrentUser('filler@example.com'); + $supervisor = $this->createUserWithEmail('Supervisor', 'supervisor@example.com'); + $this->updateItem(User::class, Session::getLoginUserID(), [ + 'users_id_supervisor' => $supervisor->getID(), + ]); + + $emails = $this->resolve([ITILActorFieldStrategy::FORM_FILLER_SUPERVISOR]); + + $this->assertSame(['supervisor@example.com'], $emails); + } + + public function testFormFillerWithNotificationsDisabled(): void + { + // Arrange: log in as a user who disabled notifications + $user = $this->createItem(User::class, [ + 'name' => 'Filler without notifications', + 'password' => 'Filler without notifications', + 'password2' => 'Filler without notifications', + 'is_notif_enable_default' => 0, + ], ['password', 'password2']); + $this->createItem(UserEmail::class, [ + 'users_id' => $user->getID(), + 'email' => 'filler@example.com', + 'is_default' => 1, + ]); + $this->login($user->fields['name'], $user->fields['name']); + + $emails = $this->resolve([ITILActorFieldStrategy::FORM_FILLER], login: false); + + $this->assertSame([], $emails); + } + + public function testSpecificAnswersEmailAndActors(): void + { + // Arrange + $user = $this->createUserWithEmail('Requester', 'requester@example.com'); + $group = $this->createItem(Group::class, ['name' => 'Observers']); + $member = $this->createUserWithEmail('Observer', 'observer@example.com'); + $this->addUserToGroup($group, $member); + $form = $this->createFormWithRecipientQuestions(); + + // Act + $emails = $this->resolve( + [ITILActorFieldStrategy::SPECIFIC_ANSWERS], + specific_question_ids: [ + $this->getQuestionId($form, 'Email'), + $this->getQuestionId($form, 'Requester'), + $this->getQuestionId($form, 'Observer'), + ], + form: $form, + answers: [ + 'Email' => 'answer@example.com', + 'Requester' => ['users_id-' . $user->getID()], + 'Observer' => ['groups_id-' . $group->getID()], + ], + ); + + // Assert + $this->assertSame( + ['answer@example.com', 'requester@example.com', 'observer@example.com'], + $emails, + ); + } + + public function testLastValidAnswer(): void + { + $builder = new FormBuilder("Last valid answer form"); + $builder->addQuestion("First email", QuestionTypeEmail::class); + $builder->addQuestion("Last email", QuestionTypeEmail::class); + + $form = $this->createForm($builder); + + $emails = $this->resolve( + [ITILActorFieldStrategy::LAST_VALID_ANSWER], + form: $form, + answers: [ + 'First email' => 'first@example.com', + 'Last email' => 'last@example.com', + ], + ); + + $this->assertSame(['last@example.com'], $emails); + } + + public function testUserFromItemAnswer(): void + { + $user = $this->createUserWithEmail('Owner', 'owner@example.com'); + $computer = $this->createItem(Computer::class, [ + 'name' => 'Owned computer', + 'entities_id' => $this->getTestRootEntity(only_id: true), + 'users_id' => $user->getID(), + ]); + $form = $this->createFormWithRecipientQuestions(); + + $emails = $this->resolve( + [ITILActorFieldStrategy::USER_FROM_OBJECT_ANSWER], + specific_question_ids: [$this->getQuestionId($form, 'Computer')], + form: $form, + answers: ['Computer' => ['itemtype' => Computer::class, 'items_id' => $computer->getID()]], + ); + + $this->assertSame(['owner@example.com'], $emails); + } + + public function testGroupFromItemAnswer(): void + { + $group = $this->createItem(Group::class, ['name' => 'Owners']); + $member = $this->createUserWithEmail('Owner', 'owner@example.com'); + $this->addUserToGroup($group, $member); + $computer = $this->createItem(Computer::class, [ + 'name' => 'Owned computer', + 'entities_id' => $this->getTestRootEntity(only_id: true), + 'groups_id' => [$group->getID()], + ], ['groups_id']); + $form = $this->createFormWithRecipientQuestions(); + + $emails = $this->resolve( + [ITILActorFieldStrategy::GROUP_FROM_OBJECT_ANSWER], + specific_question_ids: [$this->getQuestionId($form, 'Computer')], + form: $form, + answers: ['Computer' => ['itemtype' => Computer::class, 'items_id' => $computer->getID()]], + ); + + $this->assertSame(['owner@example.com'], $emails); + } + + public function testUnansweredEmailQuestion(): void + { + $form = $this->createFormWithRecipientQuestions(); + + $emails = $this->resolve( + [ITILActorFieldStrategy::SPECIFIC_ANSWERS], + specific_question_ids: [$this->getQuestionId($form, 'Email')], + form: $form, + answers: [], + ); + + $this->assertSame([], $emails); + } + + public function testDeletedSpecificUserIsIgnored(): void + { + $deleted = $this->createUserWithEmail('Deleted', 'deleted@example.com'); + $this->assertTrue($deleted->delete(['id' => $deleted->getID()])); + $purged = $this->createUserWithEmail('Purged', 'purged@example.com'); + $purged_id = $purged->getID(); + $this->assertTrue($purged->delete(['id' => $purged_id], force: true)); + + $emails = $this->resolve( + [ITILActorFieldStrategy::SPECIFIC_VALUES], + specific_itilactors_ids: [User::class => [$deleted->getID(), $purged_id]], + ); + + $this->assertSame([], $emails); + } + + public function testSupplierAnswerIsIgnored(): void + { + $supplier = $this->createItem(Supplier::class, [ + 'name' => 'Supplier', + 'email' => 'supplier@example.com', + 'entities_id' => $this->getTestRootEntity(only_id: true), + ]); + $form = $this->createFormWithRecipientQuestions(); + + $emails = $this->resolve( + [ITILActorFieldStrategy::SPECIFIC_ANSWERS], + specific_question_ids: [$this->getQuestionId($form, 'Assignee')], + form: $form, + answers: ['Assignee' => ['suppliers_id-' . $supplier->getID()]], + ); + + $this->assertSame([], $emails); + } + + public function testCombinedStrategies(): void + { + $this->addEmailToCurrentUser('filler@example.com'); + $user = $this->createUserWithEmail('Recipient', 'recipient@example.com'); + + $emails = $this->resolve( + [ITILActorFieldStrategy::SPECIFIC_VALUES, ITILActorFieldStrategy::FORM_FILLER], + specific_itilactors_ids: [User::class => [$user->getID()]], + ); + + $this->assertSame(['recipient@example.com', 'filler@example.com'], $emails); + } + + public function testFreeAddressesParsing(): void + { + $field = new EmailAddressesField(); + + $emails = $field->getEmailAddresses( + new SimpleValueConfig(' A@x.fr; b@y.fr ,, invalid ; '), + ); + + $this->assertSame(['A@x.fr', 'b@y.fr'], $emails); + } + + /** + * @param ITILActorFieldStrategy[] $strategies + * @param array $specific_itilactors_ids + * @param int[] $specific_question_ids + * @param array $answers + * @return list + */ + private function resolve( + array $strategies, + array $specific_itilactors_ids = [], + array $specific_question_ids = [], + ?Form $form = null, + array $answers = [], + bool $login = true, + ): array { + if ($login) { + $this->login(); + } + + $form ??= $this->createForm(new FormBuilder("Email form")); + $answers_set = $this->sendFormAndGetAnswerSet($form, $answers); + + $config = new EmailRecipientsFieldConfig( + strategies: $strategies, + specific_itilactors_ids: $specific_itilactors_ids, + specific_question_ids: $specific_question_ids, + ); + + return (new EmailRecipientsField())->getEmailAddresses($config, $answers_set); + } + + private function createFormWithRecipientQuestions(): Form + { + $multiple = json_encode(new QuestionTypeActorsExtraDataConfig(is_multiple_actors: true)); + + $builder = new FormBuilder("Recipients form"); + $builder->addQuestion("Email", QuestionTypeEmail::class); + $builder->addQuestion("Requester", QuestionTypeRequester::class, '', $multiple); + $builder->addQuestion("Observer", QuestionTypeObserver::class, '', $multiple); + $builder->addQuestion("Assignee", QuestionTypeAssignee::class, '', $multiple); + $builder->addQuestion( + name: "Computer", + type: QuestionTypeItem::class, + extra_data: json_encode(new QuestionTypeItemExtraDataConfig(itemtype: Computer::class)), + ); + + return $this->createForm($builder); + } + + private function addEmailToCurrentUser(string $email): void + { + $this->login(); + $this->createItem(UserEmail::class, [ + 'users_id' => Session::getLoginUserID(), + 'email' => $email, + 'is_default' => 1, + ]); + } +} diff --git a/tests/Model/Destination/Email/EmailRecipientsWarningTest.php b/tests/Model/Destination/Email/EmailRecipientsWarningTest.php new file mode 100644 index 0000000..3e8c538 --- /dev/null +++ b/tests/Model/Destination/Email/EmailRecipientsWarningTest.php @@ -0,0 +1,132 @@ +createRecipientsForm(); + + $this->assertNull($this->getWarning($form, [ITILActorFieldStrategy::SPECIFIC_VALUES])); + $this->assertNull($this->getWarning( + $form, + [ITILActorFieldStrategy::SPECIFIC_ANSWERS], + [$this->getQuestionId($form, 'Requester')], + )); + } + + public function testWarningForSpecificEmailAnswer(): void + { + $form = $this->createRecipientsForm(); + + $warning = $this->getWarning( + $form, + [ITILActorFieldStrategy::SPECIFIC_ANSWERS], + [$this->getQuestionId($form, 'Email')], + ); + + $this->assertSame(EmailDestination::getTypedRecipientsWarning(), $warning); + } + + public function testWarningForLastValidAnswer(): void + { + $this->assertSame( + EmailDestination::getTypedRecipientsWarning(), + $this->getWarning($this->createRecipientsForm(), [ITILActorFieldStrategy::LAST_VALID_ANSWER]), + ); + + // Without email question, the last valid answer is always an actor + $form = $this->createForm( + (new FormBuilder("Without email"))->addQuestion("Requester", QuestionTypeRequester::class), + ); + $this->assertNull($this->getWarning($form, [ITILActorFieldStrategy::LAST_VALID_ANSWER])); + } + + public function testStrongerWarningForUnauthenticatedAccess(): void + { + $form = $this->createRecipientsForm(allow_unauthenticated: true); + + $warning = $this->getWarning( + $form, + [ITILActorFieldStrategy::SPECIFIC_ANSWERS], + [$this->getQuestionId($form, 'Email')], + ); + + $this->assertSame(EmailDestination::getUnauthenticatedRecipientsWarning(), $warning); + } + + private function createRecipientsForm(bool $allow_unauthenticated = false): Form + { + $builder = new FormBuilder("Recipients warning form"); + $builder->addQuestion("Email", QuestionTypeEmail::class); + $builder->addQuestion("Requester", QuestionTypeRequester::class); + if ($allow_unauthenticated) { + $builder->addAccessControl( + DirectAccess::class, + new DirectAccessConfig(allow_unauthenticated: true), + ); + } + + return $this->createForm($builder); + } + + /** + * @param ITILActorFieldStrategy[] $strategies + * @param int[] $specific_question_ids + */ + private function getWarning(Form $form, array $strategies, array $specific_question_ids = []): ?string + { + $recipients_config = new EmailRecipientsFieldConfig( + strategies: $strategies, + specific_question_ids: $specific_question_ids, + ); + + return (new EmailDestination())->getRecipientsWarning($form, [ + EmailRecipientsField::getKey() => $recipients_config->jsonSerialize(), + ]); + } +} diff --git a/tests/Model/Destination/Email/EmailSenderTest.php b/tests/Model/Destination/Email/EmailSenderTest.php new file mode 100644 index 0000000..dee6288 --- /dev/null +++ b/tests/Model/Destination/Email/EmailSenderTest.php @@ -0,0 +1,409 @@ +login(); + $this->enableConfigurableItem(new EmailDestination()); + } + + public function testOneQueuedEmailPerRecipient(): void + { + // Arrange + $user = $this->createUserWithEmail('Payroll', 'payroll@example.com'); + $other = $this->createUserWithEmail('Manager', 'manager@example.com'); + $form = $this->createFormWithEmailDestination([ + EmailRecipientsField::getKey() => $this->getSpecificActorsInput($user, $other), + EmailAddressesField::getKey() => ['value' => 'accounting@example.com'], + ]); + + // Act + $answers_set = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers()); + + // Assert + $queued = $this->getQueuedEmails($answers_set); + $this->assertSame( + ['payroll@example.com', 'manager@example.com', 'accounting@example.com'], + array_column($queued, 'recipient'), + ); + foreach ($queued as $row) { + $this->assertSame('mailing', $row['mode']); + $this->assertSame(0, (int) $row['notificationtemplates_id']); + $this->assertSame((int) $form->fields['entities_id'], (int) $row['entities_id']); + $this->assertSame('forms@example.com', $row['sender']); + $this->assertSame('GLPI forms', $row['sendername']); + } + } + + public function testSubjectWithTags(): void + { + // Arrange: a subject made of answer tags + $form = $this->createFormWithEmailDestination(); + $this->updateEmailDestinationConfig($form, [ + TitleField::getKey() => [ + 'value' => 'ARRIVEE : ' . $this->getAnswerTag($form, 'First name') + . ' ' . $this->getAnswerTag($form, 'Last name'), + ], + ]); + + // Act + $answers_set = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers()); + + // Assert + $queued = $this->getQueuedEmails($answers_set); + $this->assertCount(1, $queued); + $this->assertSame('ARRIVEE : Jean Dupont', current($queued)['name']); + } + + public function testBodyIsAutoGeneratedByDefault(): void + { + $form = $this->createFormWithEmailDestination(); + + $answers_set = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers()); + + $row = current($this->getQueuedEmails($answers_set)); + $this->assertStringContainsString('First name', $row['body_html']); + $this->assertStringContainsString('Jean', $row['body_html']); + $this->assertStringContainsString('Jean', $row['body_text']); + $this->assertStringNotContainsString('<', $row['body_text']); + } + + public function testCustomBodyWithTags(): void + { + $form = $this->createFormWithEmailDestination(); + $this->updateEmailDestinationConfig($form, [ + ContentField::getKey() => ['value' => '

Bonjour ' . $this->getAnswerTag($form, 'Last name') . '

'], + ContentField::getAutoConfigKey() => 0, + ]); + + $answers_set = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers()); + + $row = current($this->getQueuedEmails($answers_set)); + $this->assertStringContainsString('Bonjour Dupont', $row['body_html']); + $this->assertStringNotContainsString('First name', $row['body_html']); + } + + public function testSubjectAndBodySentAsConfigured(): void + { + $form = $this->createFormWithEmailDestination(); + $this->updateEmailDestinationConfig($form, [ + TitleField::getKey() => ['value' => 'Exact subject'], + ContentField::getKey() => ['value' => '

Exact body

'], + ContentField::getAutoConfigKey() => 0, + ]); + + $answers_set = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers()); + + $row = current($this->getQueuedEmails($answers_set)); + $this->assertSame('Exact subject', $row['name']); + $this->assertStringContainsString('Exact body', $row['body_html']); + $this->assertStringNotContainsString('SIG-XYZ', $row['body_html']); + $this->assertStringNotContainsString('SIG-XYZ', $row['body_text']); + } + + public function testAnswerHtmlIsSanitized(): void + { + // Arrange: a rich text answer, which is not escaped by its question type + $form = $this->createFormWithEmailDestination(); + $this->updateEmailDestinationConfig($form, [ + ContentField::getKey() => ['value' => '

' . $this->getAnswerTag($form, 'Comment') . '

'], + ContentField::getAutoConfigKey() => 0, + ]); + + // Act + $answers_set = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers() + [ + 'Comment' => '

Hi

', + ]); + + // Assert + $row = current($this->getQueuedEmails($answers_set)); + $this->assertStringContainsString('Hi', $row['body_html']); + $this->assertStringNotContainsString('assertStringNotContainsString('onerror', $row['body_html']); + } + + public function testDeletedQuestionTagIsEmpty(): void + { + // Arrange: a subject referencing a question that is then deleted + $form = $this->createFormWithEmailDestination(); + $this->updateEmailDestinationConfig($form, [ + TitleField::getKey() => ['value' => 'Hello ' . $this->getAnswerTag($form, 'Send')], + ]); + $question = Question::getById($this->getQuestionId($form, 'Send')); + $this->assertTrue($question->delete(['id' => $question->getID()])); + + // Act + $answers_set = $this->sendFormAndGetAnswerSet($form, [ + 'First name' => 'Jean', + 'Last name' => 'Dupont', + ]); + + // Assert: the email is still sent, without the tag + $row = current($this->getQueuedEmails($answers_set)); + $this->assertSame('Hello', trim($row['name'])); + } + + public function testDuplicateAddressesAcrossSourcesAreMerged(): void + { + $user = $this->createUserWithEmail('Payroll', 'payroll@example.com'); + $form = $this->createFormWithEmailDestination([ + EmailRecipientsField::getKey() => $this->getSpecificActorsInput($user), + EmailAddressesField::getKey() => ['value' => 'PAYROLL@example.com'], + ]); + + $answers_set = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers()); + + $this->assertSame( + ['payroll@example.com'], + array_column($this->getQueuedEmails($answers_set), 'recipient'), + ); + } + + public function testNothingQueuedWhenFeatureDisabled(): void + { + $form = $this->createFormWithEmailDestination(); + $this->disableConfigurableItem(new EmailDestination()); + + $answers_set = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers()); + + $this->assertCount(0, $this->getQueuedEmails($answers_set)); + } + + public function testNothingQueuedWhenNotificationsDisabled(): void + { + global $CFG_GLPI; + + $form = $this->createFormWithEmailDestination(); + $CFG_GLPI['notifications_mailing'] = 0; + + $answers_set = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers()); + + $this->assertCount(0, $this->getQueuedEmails($answers_set)); + } + + public function testNothingQueuedWithoutRecipients(): void + { + $form = $this->createFormWithEmailDestination([ + EmailAddressesField::getKey() => ['value' => ''], + ]); + + $answers_set = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers()); + + $this->assertGreaterThan(0, $answers_set->getID()); + $this->assertCount(0, $this->getQueuedEmails($answers_set)); + } + + public function testNothingQueuedWithoutSender(): void + { + $form = $this->createFormWithEmailDestination(); + $this->removeEmailSender(); + + $answers_set = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers()); + + $this->assertGreaterThan(0, $answers_set->getID()); + $this->assertCount(0, $this->getQueuedEmails($answers_set)); + $this->hasPhpLogRecordThatContains( + 'No email address is defined in configuration.', + LogLevel::WARNING, + ); + } + + public function testNoCreatedItemIsLinked(): void + { + $form = $this->createFormWithEmailDestination(); + + $answers_set = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers()); + + $this->assertCount(1, $this->getQueuedEmails($answers_set)); + $this->assertSame([], $answers_set->getCreatedItems()); + } + + public function testCreatedIfCondition(): void + { + $form = $this->createFormWithEmailDestination(strategy: CreationStrategy::CREATED_IF); + + $sent = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers() + ['Send' => 'Yes']); + $not_sent = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers() + ['Send' => 'No']); + + $this->assertCount(1, $this->getQueuedEmails($sent)); + $this->assertCount(0, $this->getQueuedEmails($not_sent)); + } + + public function testCreatedUnlessCondition(): void + { + $form = $this->createFormWithEmailDestination(strategy: CreationStrategy::CREATED_UNLESS); + + $not_sent = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers() + ['Send' => 'Yes']); + $sent = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers() + ['Send' => 'No']); + + $this->assertCount(1, $this->getQueuedEmails($sent)); + $this->assertCount(0, $this->getQueuedEmails($not_sent)); + } + + public function testOtherDestinationStillCreatedWhenEmailFails(): void + { + // Arrange: a form with a ticket and an email that can't be sent + $form = $this->createFormWithEmailDestination(with_ticket: true); + $this->removeEmailSender(); + + // Act + $answers_set = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers()); + + // Assert: the ticket is created anyway + $created_items = $answers_set->getCreatedItems(); + $this->assertCount(1, $created_items); + $this->assertInstanceOf(Ticket::class, current($created_items)); + $this->assertCount(0, $this->getQueuedEmails($answers_set)); + + // Once for the email destination, once for the ticket notifications + for ($i = 0; $i < 2; $i++) { + $this->hasPhpLogRecordThatContains( + 'No email address is defined in configuration.', + LogLevel::WARNING, + ); + } + } + + public function testUnexpectedErrorDoesNotCancelSubmission(): void + { + global $DB; + + // Arrange: a stored config the fields can't read (hand-edited import, API, ...) + $form = $this->createFormWithEmailDestination(with_ticket: true); + $DB->update(FormDestination::getTable(), [ + 'config' => json_encode([EmailAddressesField::getKey() => []]), + ], ['id' => $this->getDestinationId($form, 'Email')]); + + // Act + $answers_set = $this->sendFormAndGetAnswerSet($form, $this->getDefaultAnswers()); + + // Assert: the answers and the ticket are kept, the error is logged + $this->assertGreaterThan(0, $answers_set->getID()); + $created_items = $answers_set->getCreatedItems(); + $this->assertCount(1, $created_items); + $this->assertInstanceOf(Ticket::class, current($created_items)); + $this->assertCount(0, $this->getQueuedEmails($answers_set)); + $this->hasPhpLogRecordThatContains('Undefined array key "value"', LogLevel::WARNING); + $this->hasPhpLogRecordThatContains('Caught TypeError', LogLevel::ERROR); + } + + /** @param ?array $config */ + private function createFormWithEmailDestination( + ?array $config = null, + ?CreationStrategy $strategy = null, + bool $with_ticket = false, + ): Form { + $config ??= [EmailAddressesField::getKey() => ['value' => 'hr@example.com']]; + + $builder = new FormBuilder("Arrival form"); + $builder->setShouldInitDestinations(false); + $builder->addQuestion("First name", QuestionTypeShortText::class); + $builder->addQuestion("Last name", QuestionTypeShortText::class); + $builder->addQuestion("Send", QuestionTypeShortText::class); + $builder->addQuestion("Comment", QuestionTypeLongText::class); + $builder->addDestination(EmailDestination::class, 'Email', $config); + if ($with_ticket) { + $builder->addDestination(FormDestinationTicket::class, 'Ticket'); + } + + if ($strategy instanceof CreationStrategy) { + $builder->setDestinationCondition('Email', $strategy, [ + [ + 'logic_operator' => LogicOperator::AND, + 'item_name' => "Send", + 'item_type' => Type::QUESTION, + 'value_operator' => ValueOperator::EQUALS, + 'value' => "Yes", + ], + ]); + } + + return $this->createForm($builder); + } + + /** @return array */ + private function getDefaultAnswers(): array + { + return [ + 'First name' => 'Jean', + 'Last name' => 'Dupont', + ]; + } + + private function removeEmailSender(): void + { + global $CFG_GLPI; + + $CFG_GLPI['from_email'] = ''; + $CFG_GLPI['admin_email'] = ''; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 4716ab6..fb97319 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -44,3 +44,4 @@ require __DIR__ . "/Front/FrontTestCase.php"; require __DIR__ . "/Model/Mapper/MapperTestCase.php"; require __DIR__ . "/Model/QuestionType/QuestionTypeTestCase.php"; +require __DIR__ . "/Model/Destination/Email/EmailDestinationTestTrait.php";