From fed149dc66df0b9188e66fa481256fe69867c4e8 Mon Sep 17 00:00:00 2001 From: Denis Date: Tue, 13 Jun 2023 09:51:32 +0200 Subject: [PATCH] =?UTF-8?q?N=C2=B0918=20-=20Translate=20placeholders=20in?= =?UTF-8?q?=20notifications=20(#506)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Localization of date & time formats - Use of DataLocalizer (if present) - All placeholders can be used in the uploaded HTML template as well as in the notification "message" --- core/action.class.inc.php | 369 ++++++++++++------ core/attributedef.class.inc.php | 4 +- dictionaries/en.dictionary.itop.core.php | 13 + dictionaries/fr.dictionary.itop.core.php | 14 +- .../email-notification-preview.html.twig | 13 + .../unitary-tests/core/ActionEmailTest.php | 235 ++++++++++- 6 files changed, 530 insertions(+), 118 deletions(-) create mode 100644 templates/datamodel/ActionEmail/email-notification-preview.html.twig diff --git a/core/action.class.inc.php b/core/action.class.inc.php index b07ad65ac..756f98a8f 100644 --- a/core/action.class.inc.php +++ b/core/action.class.inc.php @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with iTop. If not, see +use Combodo\iTop\Application\TwigBase\Twig\TwigHelper; /** * Persistent classes (internal): user defined actions @@ -225,7 +226,22 @@ class ActionEmail extends ActionNotification * @since 3.0.1 */ const ENUM_HEADER_NAME_REFERENCES = 'References'; - + /** + * @var string + * @since 3.1.0 + */ + const TEMPLATE_BODY_CONTENT = '$content$'; + /** + * Wraps the 'body' of the message for previewing inside an IFRAME -- i.e. without any of the iTop stylesheets being applied + * @var string + * @since 3.1.0 + */ + const CONTENT_HIGHLIGHT = '
$content$
%s
'; + /** + * Wraps a placeholder of the email's body for previewing inside an IFRAME -- i.e. without any of the iTop stylesheets being applied + * @var string + */ + const FIELD_HIGHLIGHT = '\\$$1\\$'; /** * @inheritDoc */ @@ -257,6 +273,10 @@ class ActionEmail extends ActionNotification MetaModel::Init_AddAttribute(new AttributeTemplateString("subject", array("allowed_values" => null, "sql" => "subject", "default_value" => null, "is_null_allowed" => false, "depends_on" => array()))); MetaModel::Init_AddAttribute(new AttributeTemplateHTML("body", array("allowed_values" => null, "sql" => "body", "default_value" => null, "is_null_allowed" => false, "depends_on" => array()))); MetaModel::Init_AddAttribute(new AttributeEnum("importance", array("allowed_values" => new ValueSetEnum('low,normal,high'), "sql" => "importance", "default_value" => 'normal', "is_null_allowed" => false, "depends_on" => array()))); + MetaModel::Init_AddAttribute(new AttributeApplicationLanguage("language", array("sql"=>"language", "default_value"=>null, "is_null_allowed"=>true, "depends_on"=>array()))); + MetaModel::Init_AddAttribute(new AttributeBlob("html_template", array("is_null_allowed"=>true, "depends_on"=>array(), "always_load_in_tables"=>false))); + MetaModel::Init_AddAttribute(new AttributeEnum("ignore_notify", array("allowed_values" => new ValueSetEnum('yes,no'), "sql" => "ignore_notify", "default_value" => 'yes', "is_null_allowed" => false, "depends_on" => array()))); + // Display lists // - Attributes to be displayed for the complete details @@ -266,8 +286,10 @@ class ActionEmail extends ActionNotification 0 => 'name', 1 => 'description', 2 => 'status', - 3 => 'subject', - 4 => 'body', + 3 => 'language', + 4 => 'html_template', + 5 => 'subject', + 6 => 'body', // 5 => 'importance', not handled when sending the mail, better hide it then ), 'fieldset:ActionEmail:trigger' => array( @@ -281,20 +303,21 @@ class ActionEmail extends ActionNotification 2 => 'reply_to', 3 => 'reply_to_label', 4 => 'test_recipient', - 5 => 'to', - 6 => 'cc', - 7 => 'bcc', + 5 => 'ignore_notify', + 6 => 'to', + 7 => 'cc', + 8 => 'bcc', ), ), )); // - Attributes to be displayed for a list - MetaModel::Init_SetZListItems('list', array('status', 'to', 'subject')); + MetaModel::Init_SetZListItems('list', array('status', 'to', 'subject', 'language')); // Search criteria // - Standard criteria of the search - MetaModel::Init_SetZListItems('standard_search', array('name', 'description', 'status', 'subject')); + MetaModel::Init_SetZListItems('standard_search', array('name', 'description', 'status', 'subject', 'language')); // - Default criteria for the search - MetaModel::Init_SetZListItems('default_search', array('name', 'description', 'status', 'subject')); + MetaModel::Init_SetZListItems('default_search', array('name', 'description', 'status', 'subject', 'language')); } // count the recipients found @@ -324,6 +347,15 @@ class ActionEmail extends ActionNotification try { $oSearch = DBObjectSearch::FromOQL($sOQL); + if ($this->Get('ignore_notify') === 'no') { + // In theory it is possible to notify *any* kind of object, + // as long as there is an email attribute in the class + // So let's not assume that the selected class is a Person + $sFirstSelectedClass = $oSearch->GetClass(); + if (MetaModel::IsValidAttCode($sFirstSelectedClass, 'notify')) { + $oSearch->AddCondition('notify', 'yes'); + } + } $oSearch->AllowAllData(); } catch (OQLException $e) @@ -448,114 +480,27 @@ class ActionEmail extends ActionNotification */ protected function _DoExecute($oTrigger, $aContextArgs, &$oLog) { - $sPreviousUrlMaker = ApplicationContext::SetUrlMakerClass(); - try - { - $this->m_iRecipients = 0; - $this->m_aMailErrors = array(); - - // Determine recipients - // - $sTo = $this->FindRecipients('to', $aContextArgs); - $sCC = $this->FindRecipients('cc', $aContextArgs); - $sBCC = $this->FindRecipients('bcc', $aContextArgs); - - $sFrom = MetaModel::ApplyParams($this->Get('from'), $aContextArgs); - $sFromLabel = MetaModel::ApplyParams($this->Get('from_label'), $aContextArgs); - $sReplyTo = MetaModel::ApplyParams($this->Get('reply_to'), $aContextArgs); - $sReplyToLabel = MetaModel::ApplyParams($this->Get('reply_to_label'), $aContextArgs); - - $sSubject = MetaModel::ApplyParams($this->Get('subject'), $aContextArgs); - $sBody = MetaModel::ApplyParams($this->Get('body'), $aContextArgs); - - $oObj = $aContextArgs['this->object()']; - $sMessageId = $this->GenerateIdentifierForHeaders($oObj, static::ENUM_HEADER_NAME_MESSAGE_ID); - $sReference = $this->GenerateIdentifierForHeaders($oObj, static::ENUM_HEADER_NAME_REFERENCES); - } - catch (Exception $e) { - /** @noinspection PhpUnhandledExceptionInspection */ - throw $e; - } - finally { - ApplicationContext::SetUrlMakerClass($sPreviousUrlMaker); - } - - if (!is_null($oLog)) { - // Note: we have to secure this because those values are calculated - // inside the try statement, and we would like to keep track of as - // many data as we could while some variables may still be undefined - if (isset($sTo)) { - $oLog->Set('to', $sTo); - } - if (isset($sCC)) { - $oLog->Set('cc', $sCC); - } - if (isset($sBCC)) { - $oLog->Set('bcc', $sBCC); - } - if (isset($sFrom)) { - $oLog->Set('from', $sFrom); - } - if (isset($sSubject)) { - $oLog->Set('subject', $sSubject); - } - if (isset($sBody)) { - $oLog->Set('body', $sBody); - } - } $sStyles = file_get_contents(APPROOT.'css/email.css'); $sStyles .= MetaModel::GetConfig()->Get('email_css'); - + $oEmail = new EMail(); - - if ($this->IsBeingTested()) { - $oEmail->SetSubject('TEST['.$sSubject.']'); - $sTestBody = $sBody; - $sTestBody .= "
\n"; - $sTestBody .= "

Testing email notification ".$this->GetHyperlink()."

\n"; - $sTestBody .= "

The email should be sent with the following properties\n"; - $sTestBody .= "

    \n"; - $sTestBody .= "
  • TO: $sTo
  • \n"; - $sTestBody .= "
  • CC: $sCC
  • \n"; - $sTestBody .= "
  • BCC: $sBCC
  • \n"; - $sTestBody .= empty($sFromLabel) ? "
  • From: $sFrom
  • \n" : "
  • From: $sFromLabel <$sFrom>
  • \n"; - $sTestBody .= empty($sReplyToLabel) ? "
  • Reply-To: $sReplyTo
  • \n" : "
  • Reply-To: $sReplyToLabel <$sReplyTo>
  • \n"; - $sTestBody .= "
  • References: $sReference
  • \n"; - $sTestBody .= "
\n"; - $sTestBody .= "

\n"; - $sTestBody .= "
\n"; - $oEmail->SetBody($sTestBody, 'text/html', $sStyles); - $oEmail->SetRecipientTO($this->Get('test_recipient')); - $oEmail->SetRecipientFrom($sFrom, $sFromLabel); - $oEmail->SetReferences($sReference); - $oEmail->SetMessageId($sMessageId); - // Note: N°4849 We pass the "References" identifier instead of the "Message-ID" on purpose as we want notifications emails to group around the triggering iTop object, not just the users' replies to the notification - $oEmail->SetInReplyTo($sReference); - } else { - $oEmail->SetSubject($sSubject); - $oEmail->SetBody($sBody, 'text/html', $sStyles); - $oEmail->SetRecipientTO($sTo); - $oEmail->SetRecipientCC($sCC); - $oEmail->SetRecipientBCC($sBCC); - $oEmail->SetRecipientFrom($sFrom, $sFromLabel); - $oEmail->SetRecipientReplyTo($sReplyTo, $sReplyToLabel); - $oEmail->SetReferences($sReference); - $oEmail->SetMessageId($sMessageId); - // Note: N°4849 We pass the "References" identifier instead of the "Message-ID" on purpose as we want notifications emails to group around the triggering iTop object, not just the users' replies to the notification - $oEmail->SetInReplyTo($sReference); + + $aEmailContent = $this->PrepareMessageContent($aContextArgs, $oLog); + $oEmail->SetSubject($aEmailContent['subject']); + $oEmail->SetBody($aEmailContent['body'], 'text/html', $sStyles); + $oEmail->SetRecipientTO($aEmailContent['to']); + $oEmail->SetRecipientCC($aEmailContent['cc']); + $oEmail->SetRecipientBCC($aEmailContent['bcc']); + $oEmail->SetRecipientFrom($aEmailContent['from'], $aEmailContent['from_label']); + $oEmail->SetRecipientReplyTo($aEmailContent['reply_to'], $aEmailContent['reply_to_label']); + $oEmail->SetReferences($aEmailContent['references']); + $oEmail->SetMessageId($aEmailContent['message_id']); + $oEmail->SetInReplyTo($aEmailContent['in_reply_to']); + + foreach($aEmailContent['attachments'] as $aAttachment) { + $oEmail->AddAttachment($aAttachment['data'], $aAttachment['filename'], $aAttachment['mime_type']); } - - if (isset($aContextArgs['attachments'])) - { - $aAttachmentReport = array(); - foreach($aContextArgs['attachments'] as $oDocument) - { - $oEmail->AddAttachment($oDocument->GetData(), $oDocument->GetFileName(), $oDocument->GetMimeType()); - $aAttachmentReport[] = array($oDocument->GetFileName(), $oDocument->GetMimeType(), strlen($oDocument->GetData())); - } - $oLog->Set('attachments', $aAttachmentReport); - } - + if (empty($this->m_aMailErrors)) { if ($this->m_iRecipients == 0) @@ -564,6 +509,7 @@ class ActionEmail extends ActionNotification } else { + $aErrors = []; $iRes = $oEmail->Send($aErrors, false, $oLog); // allow asynchronous mode switch ($iRes) { @@ -588,13 +534,147 @@ class ActionEmail extends ActionNotification } } + /** + * @param array $aContextArgs + * @param \EventNotification $oLog + * + * @return array + * @throws \CoreException + * @throws \Exception + * @since 3.1.0 N°918 + */ + protected function PrepareMessageContent($aContextArgs, &$oLog): array + { + $aMessageContent = [ + 'to' => '', + 'cc' => '', + 'bcc' => '', + 'from' => '', + 'from_label' => '', + 'reply_to' => '', + 'reply_to_label' => '', + 'subject' => '', + 'body' => '', + 'references' => '', + 'message_id' => '', + 'in_reply_to' => '', + 'attachments' => [], + ]; + $sPreviousUrlMaker = ApplicationContext::SetUrlMakerClass(); + $sPreviousLanguage = Dict::GetUserLanguage(); + $aPreviousPluginProperties = ApplicationContext::GetPluginProperties('QueryLocalizerPlugin'); + if ($this->Get('language') !== '') { + // If a language is specified for this action, force this language + // when rendering all placeholders inside this message + Dict::SetUserLanguage($this->Get('language')); + AttributeDateTime::LoadFormatFromConfig(); + ApplicationContext::SetPluginProperty('QueryLocalizerPlugin', 'language_code', $this->Get('language')); + } + + try + { + $this->m_iRecipients = 0; + $this->m_aMailErrors = array(); + + // Determine recipients + // + $aMessageContent['to'] = $this->FindRecipients('to', $aContextArgs); + $aMessageContent['cc'] = $this->FindRecipients('cc', $aContextArgs); + $aMessageContent['bcc'] = $this->FindRecipients('bcc', $aContextArgs); + + $aMessageContent['from'] = MetaModel::ApplyParams($this->Get('from'), $aContextArgs); + $aMessageContent['from_label'] = MetaModel::ApplyParams($this->Get('from_label'), $aContextArgs); + $aMessageContent['reply_to'] = MetaModel::ApplyParams($this->Get('reply_to'), $aContextArgs); + $aMessageContent['reply_to_label'] = MetaModel::ApplyParams($this->Get('reply_to_label'), $aContextArgs); + + $aMessageContent['subject'] = MetaModel::ApplyParams($this->Get('subject'), $aContextArgs); + $sBody = $this->BuildMessageBody(false); + $aMessageContent['body'] = MetaModel::ApplyParams($sBody, $aContextArgs); + + $oObj = $aContextArgs['this->object()']; + $aMessageContent['message_id'] = $this->GenerateIdentifierForHeaders($oObj, static::ENUM_HEADER_NAME_MESSAGE_ID); + $aMessageContent['references'] = $this->GenerateIdentifierForHeaders($oObj, static::ENUM_HEADER_NAME_REFERENCES); + } + catch (Exception $e) { + /** @noinspection PhpUnhandledExceptionInspection */ + throw $e; + } + finally { + ApplicationContext::SetUrlMakerClass($sPreviousUrlMaker); + Dict::SetUserLanguage($sPreviousLanguage); + AttributeDateTime::LoadFormatFromConfig(); + ApplicationContext::SetPluginProperty('QueryLocalizerPlugin', 'language_code', $aPreviousPluginProperties['language_code'] ?? null); + } + + if (!is_null($oLog)) { + // Note: we have to secure this because those values are calculated + // inside the try statement, and we would like to keep track of as + // many data as we could while some variables may still be undefined + if (isset($aMessageContent['to'])) { + $oLog->Set('to', $aMessageContent['to']); + } + if (isset($aMessageContent['cc'])) { + $oLog->Set('cc', $aMessageContent['cc']); + } + if (isset($aMessageContent['bcc'])) { + $oLog->Set('bcc', $aMessageContent['bcc']); + } + if (isset($aMessageContent['from'])) { + $oLog->Set('from', $aMessageContent['from']); + } + if (isset($aMessageContent['subject'])) { + $oLog->Set('subject', $aMessageContent['subject']); + } + if (isset($aMessageContent['body'])) { + $oLog->Set('body', HTMLSanitizer::Sanitize($aMessageContent['body'])); + } + } + $sStyles = file_get_contents(APPROOT.'css/email.css'); + $sStyles .= MetaModel::GetConfig()->Get('email_css'); + + if ($this->IsBeingTested()) { + $sTestBody = $aMessageContent['body']; + $sTestBody .= "
\n"; + $sTestBody .= "

Testing email notification ".$this->GetHyperlink()."

\n"; + $sTestBody .= "

The email should be sent with the following properties\n"; + $sTestBody .= "

    \n"; + $sTestBody .= "
  • TO: {$aMessageContent['to']}
  • \n"; + $sTestBody .= "
  • CC: {$aMessageContent['cc']}
  • \n"; + $sTestBody .= "
  • BCC: {$aMessageContent['bcc']}
  • \n"; + $sTestBody .= empty($aMessageContent['from_label']) ? "
  • From: {$aMessageContent['from']}
  • \n" : "
  • From: {$aMessageContent['from_label']} <{$aMessageContent['from']}>
  • \n"; + $sTestBody .= empty($aMessageContent['reply_to_label']) ? "
  • Reply-To: {$aMessageContent['reply_to']}
  • \n" : "
  • Reply-To: {$aMessageContent['reply_to_label']} <{$aMessageContent['reply_to']}>
  • \n"; + $sTestBody .= "
  • References: {$aMessageContent['references']}
  • \n"; + $sTestBody .= "
\n"; + $sTestBody .= "

\n"; + $sTestBody .= "
\n"; + $aMessageContent['subject'] = 'TEST['.$aMessageContent['subject'].']'; + $aMessageContent['body'] = $sTestBody; + $aMessageContent['to'] = $this->Get('test_recipient'); + } + // Note: N°4849 We pass the "References" identifier instead of the "Message-ID" on purpose as we want notifications emails to group around the triggering iTop object, not just the users' replies to the notification + $aMessageContent['in_reply_to'] = $aMessageContent['references']; + + if (isset($aContextArgs['attachments'])) + { + $aAttachmentReport = array(); + foreach($aContextArgs['attachments'] as $oDocument) + { + $aMessageContent['attachments'][] = ['data' => $oDocument->GetData(), 'filename' => $oDocument->GetFileName(), 'mime_type' => $oDocument->GetMimeType()]; + $aAttachmentReport[] = array($oDocument->GetFileName(), $oDocument->GetMimeType(), strlen($oDocument->GetData())); + } + $oLog->Set('attachments', $aAttachmentReport); + } + + return $aMessageContent; + } + /** * @param \DBObject $oObject * @param string $sHeaderName {@see \ActionEmail::ENUM_HEADER_NAME_REFERENCES}, {@see \ActionEmail::ENUM_HEADER_NAME_MESSAGE_ID} * * @return string The formatted identifier for $sHeaderName based on $oObject * @throws \Exception - * @since 3.0.1 N°4849 + * @since 3.1.0 N°4849 */ protected function GenerateIdentifierForHeaders(DBObject $oObject, string $sHeaderName): string { @@ -629,4 +709,65 @@ class ActionEmail extends ActionNotification ]); throw new Exception($sErrorMessage); } + + /** + * Compose the body of the message from the 'body' attribute and the HTML template (if any) + * @since 3.1.0 N°4849 + * @param bool $bHighlightPlaceholders If true add some extra HTML around placeholders to highlight them + * @return string + */ + protected function BuildMessageBody(bool $bHighlightPlaceholders = false): string + { + $sBody = $this->Get('body'); + /** @var ormDocument $oHtmlTemplate */ + $oHtmlTemplate = $this->Get('html_template'); + if ($oHtmlTemplate && !$oHtmlTemplate->IsEmpty()) { + $sHtmlTemplate = $oHtmlTemplate->GetData(); + if (false !== mb_strpos($sHtmlTemplate, static::TEMPLATE_BODY_CONTENT)) { + if ($bHighlightPlaceholders) { + // Highlight the $content$ block + $sBody = sprintf(static::CONTENT_HIGHLIGHT, $sBody); + } + $sBody = str_replace(static::TEMPLATE_BODY_CONTENT, $sBody, $oHtmlTemplate->GetData()); // str_replace is ok as long as both strings are well-formed UTF-8 + } else { + $sBody = $oHtmlTemplate->GetData(); + } + } + if($bHighlightPlaceholders) { + // Highlight all placeholders + $sBody = preg_replace('/\\$([^$]+)\\$/', static::FIELD_HIGHLIGHT, $sBody); + } + return $sBody; + } + + /** + * @since 3.1.0 N°4849 + * @inheritDoc + * @see cmdbAbstractObject::DisplayBareRelations() + */ + public function DisplayBareRelations(WebPage $oPage, $bEditMode = false) + { + parent::DisplayBareRelations($oPage, false); + if (!$bEditMode) { + $oPage->SetCurrentTab('action_email__preview', Dict::S('ActionEmail:preview_tab'), Dict::S('ActionEmail:preview_tab+')); + $sBody = $this->BuildMessageBody(true); + TwigHelper::RenderIntoPage($oPage, APPROOT.'/', 'templates/datamodel/ActionEmail/email-notification-preview', ['iframe_content' => $sBody]); + } + } + + /** + * @since 3.1.0 + * @inheritDoc + * @see cmdbAbstractObject::DoCheckToWrite() + */ + public function DoCheckToWrite() + { + parent::DoCheckToWrite(); + $oHtmlTemplate = $this->Get('html_template'); + if ($oHtmlTemplate && !$oHtmlTemplate->IsEmpty()) { + if (false === mb_strpos($oHtmlTemplate->GetData(), static::TEMPLATE_BODY_CONTENT)) { + $this->m_aCheckWarnings[] = Dict::Format('ActionEmail:content_placeholder_missing', static::TEMPLATE_BODY_CONTENT, Dict::S('Class:ActionEmail/Attribute:body')); + } + } + } } diff --git a/core/attributedef.class.inc.php b/core/attributedef.class.inc.php index 9dcbdd381..936d36c92 100644 --- a/core/attributedef.class.inc.php +++ b/core/attributedef.class.inc.php @@ -6214,7 +6214,7 @@ class AttributeDateTime extends AttributeDBField /** * Load the 3 settings: date format, time format and data_time format from the configuration */ - protected static function LoadFormatFromConfig() + public static function LoadFormatFromConfig() { $aFormats = MetaModel::GetConfig()->Get('date_and_time_format'); $sLang = Dict::GetUserLanguage(); @@ -8343,7 +8343,7 @@ class AttributeBlob extends AttributeDefinition $aValues[$this->GetCode().'_data'] = ''; $aValues[$this->GetCode().'_mimetype'] = ''; $aValues[$this->GetCode().'_filename'] = ''; - $aValues[$this->GetCode().'_downloads_count'] = ''; // Note: Should this be set to \ormDocument::DEFAULT_DOWNLOADS_COUNT ? + $aValues[$this->GetCode().'_downloads_count'] = \ormDocument::DEFAULT_DOWNLOADS_COUNT; } return $aValues; diff --git a/dictionaries/en.dictionary.itop.core.php b/dictionaries/en.dictionary.itop.core.php index 11bd52d67..5538e7c0c 100644 --- a/dictionaries/en.dictionary.itop.core.php +++ b/dictionaries/en.dictionary.itop.core.php @@ -569,9 +569,22 @@ While editing, click on the magnifier to get pertinent examples', 'Class:ActionEmail/Attribute:importance/Value:normal+' => '', 'Class:ActionEmail/Attribute:importance/Value:high' => 'High', 'Class:ActionEmail/Attribute:importance/Value:high+' => '', + 'Class:ActionEmail/Attribute:language' => 'Language', + 'Class:ActionEmail/Attribute:language+' => 'Language to use for placeholders ($xxx$) inside the message (state, importance, priority, etc)', + 'Class:ActionEmail/Attribute:html_template' => 'HTML template', + 'Class:ActionEmail/Attribute:html_template+' => 'Optional HTML template wrapping around the content of the \'Body\' attribute below, useful for tailored email layouts (in the template, content of the \'Body\' attribute will replace the $content$ placeholder)', + 'Class:ActionEmail/Attribute:ignore_notify' => 'Ignore the Notify flag', + 'Class:ActionEmail/Attribute:ignore_notify+' => 'If set to \'Yes\' the \'Notify\' flag on Contacts has no effect.', + 'Class:ActionEmail/Attribute:ignore_notify/Value:no' => 'No', + 'Class:ActionEmail/Attribute:ignore_notify/value:yes' => 'Yes', 'ActionEmail:main' => 'Message', 'ActionEmail:trigger' => 'Triggers', 'ActionEmail:recipients' => 'Contacts', + 'ActionEmail:preview_tab' => 'Preview', + 'ActionEmail:preview_tab+' => 'Preview of the eMail template', + 'ActionEmail:preview_warning' => 'The actual eMail may look different in the eMail client than this preview in your browser.', + 'ActionEmail:preview_more_info' => 'For more information about the CSS features supported by the different eMail clients, refer to %1$s', + 'ActionEmail:content_placeholder_missing' => 'The placeholder "%1$s" was not found in the HTML template. The content of the field "%2$s" will not be included in the generated emails.', )); // diff --git a/dictionaries/fr.dictionary.itop.core.php b/dictionaries/fr.dictionary.itop.core.php index eac9f864c..605852be0 100644 --- a/dictionaries/fr.dictionary.itop.core.php +++ b/dictionaries/fr.dictionary.itop.core.php @@ -569,10 +569,22 @@ En édition, cliquez sur la loupe pour obtenir des exemples pertinents.', 'Class:ActionEmail/Attribute:importance/Value:normal+' => '', 'Class:ActionEmail/Attribute:importance/Value:high' => 'Haute', 'Class:ActionEmail/Attribute:importance/Value:high+' => '', + 'Class:ActionEmail/Attribute:language' => 'Langue', + 'Class:ActionEmail/Attribute:language+' => 'Langue utilisée pour les placeholders ($xxx$) dans le message (statut, importance, priorité, etc)', + 'Class:ActionEmail/Attribute:html_template' => 'Modèle HTML', + 'Class:ActionEmail/Attribute:html_template+' => 'Optionnel, modèle HTML encapsulant le contenu du champ \'Message\' ci-dessous, utile pour des mises en page sur mesure (dans le modèle, le contenu du champ \'Message\' remplacera le mot-clé $content$)', + 'Class:ActionEmail/Attribute:ignore_notify' => 'Ignorer la préférence \'Notification\'', + 'Class:ActionEmail/Attribute:ignore_notify+' => 'Si \'Oui\', le champ \'Notification\' des Contacts est ignoré.', + 'Class:ActionEmail/Attribute:ignore_notify/Value:no' => 'Non', + 'Class:ActionEmail/Attribute:ignore_notify/Value:yes' => 'Oui', 'ActionEmail:main' => 'Message', 'ActionEmail:trigger' => 'Conditions de déclenchement', 'ActionEmail:recipients' => 'Contacts', - + 'ActionEmail:preview_tab' => 'Aperçu', + 'ActionEmail:preview_tab+' => 'Aperçu du modèle de mèl', + 'ActionEmail:preview_warning' => 'Le mèl peut s\'afficher différement dans les clients mèl par rapport à cet aperçu dans votre navigateur.', + 'ActionEmail:preview_more_info' => 'Pour plus d\'informations sur les fonctionnalités CSS supportées par les différents client mèl, consultez %1$s.', + 'ActionEmail:content_placeholder_missing' => 'The mot-clé "%1$s" ne figure pas dans le modèle HTML. Le contenu du champ "%2$s" ne sera pas intégré dans les mèls générés.', )); // diff --git a/templates/datamodel/ActionEmail/email-notification-preview.html.twig b/templates/datamodel/ActionEmail/email-notification-preview.html.twig new file mode 100644 index 000000000..717659f6b --- /dev/null +++ b/templates/datamodel/ActionEmail/email-notification-preview.html.twig @@ -0,0 +1,13 @@ +{% apply spaceless %} +{% UIAlert ForWarning{sTitle:'', sContent: '', sId: null} %} +
+
+
+
{{ 'ActionEmail:preview_warning'|dict_s }}
{{ 'ActionEmail:preview_more_info'|dict_format('www.canimeail.com')|raw }}
+
+
+{% EndUIAlert %} +
+ +
+{% endapply %} \ No newline at end of file diff --git a/tests/php-unit-tests/unitary-tests/core/ActionEmailTest.php b/tests/php-unit-tests/unitary-tests/core/ActionEmailTest.php index 3f78df997..f8a7ed97d 100644 --- a/tests/php-unit-tests/unitary-tests/core/ActionEmailTest.php +++ b/tests/php-unit-tests/unitary-tests/core/ActionEmailTest.php @@ -7,6 +7,7 @@ use Combodo\iTop\Test\UnitTest\ItopDataTestCase; use Exception; use MetaModel; use utils; +use Dict; /** * @runTestsInSeparateProcesses @@ -23,11 +24,22 @@ class ActionEmailTest extends ItopDataTestCase /** @var \ActionEmail|null Temp ActionEmail created for tests */ protected static $oActionEmail = null; + + /** @var \ormDocument|null Temp ormDocument for tests */ + protected static $oDocument = null; + + /** @var \UserRequest|null Temp ormDocument for tests */ + protected static $oUserRequest = null; + + /** @var string[] Dict formatted message, because the Dict class is not available in providers */ + protected static $aWarningMessages; protected function setUp(): void { parent::setUp(); + $this->RequireOnceItopFile('application/Html2Text.php'); + static::$oActionEmail = MetaModel::NewObject('ActionEmail', [ 'name' => 'Test action', 'status' => 'disabled', @@ -35,7 +47,27 @@ class ActionEmailTest extends ItopDataTestCase 'subject' => 'Test subject', 'body' => 'Test body', ]); + + $sHtml = +<< + + + + +HTML + ; + static::$oActionEmail->DBInsert(); + static::$oDocument = new \ormDocument($sHtml, 'text/html', 'sample.html'); + static::$oUserRequest = MetaModel::NewObject('UserRequest', [ + 'title' => 'Test UserRequest', + 'description' => '

Multi-line
description

' + ]); + + static::$aWarningMessages = [ + 'warning-missing-content' => Dict::Format('ActionEmail:content_placeholder_missing', '$content$', Dict::S('Class:ActionEmail/Attribute:body')) + ]; } /** @@ -77,7 +109,6 @@ class ActionEmailTest extends ItopDataTestCase $this->assertEquals($sExpectedIdentifier, $sTestedIdentifier); break; } - } public function GenerateIdentifierForHeadersProvider() @@ -88,4 +119,206 @@ class ActionEmailTest extends ItopDataTestCase 'IncorrectHeaderName' => ['IncorrectHeaderName'], ]; } + + /** + * @dataProvider prepareMessageContentProvider + */ + public function testPrepareMessageContent($sCurrentUserLanguage, $aActionFields, $aFieldsToCheck) + { + \Dict::SetUserLanguage($sCurrentUserLanguage); + $aContext = ['this->object()' => static::$oUserRequest]; + $oActionEmail = MetaModel::NewObject('ActionEmail', [ + 'name' => 'Test action', + 'status' => 'disabled', + 'from' => 'unit-test@openitop.org', + 'subject' => 'Test subject', + 'body' => 'Test body', + ]); + foreach($aActionFields as $sCode => $value) { + if ($sCode === 'html_template') { + // special case since the data provider cannot create ormDocument objects + $oActionEmail->Set($sCode, static::$oDocument); + } else { + $oActionEmail->Set($sCode, $value); + } + + } + $oActionEmail->DBInsert(); + + $oOrg = $this->CreateOrganization('testPrepareMessageContent'); + + $oContact1 = MetaModel::NewObject('Person', [ + 'name' => 'Person 1', + 'first_name' => 'PrepareMessageContent', + 'org_id' => $oOrg->GetKey(), + 'email' => 'some.valid@email.com', + 'notify' => 'yes', + ]); + $oContact1->DBInsert(); + + + $oContact2 = MetaModel::NewObject('Person', [ + 'name' => 'Person 2', + 'first_name' => 'PrepareMessageContent', + 'org_id' => $oOrg->GetKey(), + 'email' => 'some.valid2@email.com', + 'notify' => 'no', + ]); + $oContact2->DBInsert(); + + $oLog = null; + + $aEmailContent = $this->InvokeNonPublicMethod('\ActionEmail', 'PrepareMessageContent', $oActionEmail, [$aContext, &$oLog]); + // Normalize the content of the body to simplify the comparison, useful when status == test + $aEmailContent['body'] = preg_replace('/title="[^"]+"/', 'title="****"', $aEmailContent['body']); + $aEmailContent['body'] = preg_replace('/class="object-ref-link" href="[^"]+"/', 'class="object-ref-link" href="****"', $aEmailContent['body']); + $aEmailContent['body'] = preg_replace('/References: <[^>]+>/', 'References: ****', $aEmailContent['body']); + foreach($aFieldsToCheck as $sCode => $expectedValue) { + $this->assertEquals($expectedValue, $aEmailContent[$sCode]); + } + } + + public function prepareMessageContentProvider() + { + return [ + 'subject-no-placeholder' => [ + 'EN US', + ['subject' => 'This is a test'], + ['subject' => 'This is a test'], + ], + 'subject-with-placeholder' => [ + 'EN US', + ['subject' => 'Ticket "$this->title$" created'], + ['subject' => 'Ticket "Test UserRequest" created'], + ], + 'simple-to-oql' => [ + 'EN US', + ['to' => "SELECT Person WHERE email='some.valid@email.com'"], + ['to' => 'some.valid@email.com'], + ], + 'simple-to-oql_ignoring_ignore_notify' => [ + 'EN US', + ['to' => "SELECT Person WHERE email='some.valid2@email.com'"], + ['to' => 'some.valid2@email.com'], // contact2 has 'notify' set to 'no' BUT by default when don't care + ], + 'simple-to-oql-not-bypassing-notify' => [ + 'EN US', + ['to' => "SELECT Person WHERE email='some.valid2@email.com'", 'ignore_notify' => 'no'], + ['to' => ''], // contact2 has 'notify' set to 'no' + ], + 'subject-with-localized-placeholder (default behavior)' => [ + 'EN US', + ['subject' => 'Ticket in state "$this->label(status)$" created'], + ['subject' => 'Ticket in state "New" created'], + ], + 'subject-with-localized-placeholder (default behavior 2)' => [ + 'FR FR', + ['subject' => 'Ticket in state "$this->label(status)$" created'], + ['subject' => 'Ticket in state "Nouveau" created'], + ], + 'subject-with-localized-placeholder (new behavior)' => [ + 'FR FR', + ['subject' => 'Ticket in state "$this->label(status)$" created', 'language' => 'EN US'], + ['subject' => 'Ticket in state "New" created'], + ], + 'simple-body-with-placeholder' => [ + 'EN US', + ['body' => '

Ticket "$this->title$" created.

'], + ['body' => '

Ticket "Test UserRequest" created.

'], + ], + 'simple-body-with-placeholder-TEST-mode' => [ + 'EN US', + ['body' => '

Ticket "$this->title$" created.

', 'status' => 'test'], + ['body' => +<<Ticket "Test UserRequest" created.

+

Testing email notification Test action

+

The email should be sent with the following properties +

    +
  • TO:
  • +
  • CC:
  • +
  • BCC:
  • +
  • From: unit-test@openitop.org
  • +
  • Reply-To:
  • +
  • References: ****
  • +
+

+
+ +HTML + , + 'subject' => 'TEST[Test subject]', + ], + ], + 'more-complex-body-and-title-with-placeholder' => [ + 'EN US', + ['subject' => 'Ticket "$this->title$" created'], + ['subject' => 'Ticket "Test UserRequest" created'], + ['body' => '

Ticket "$this->title$" created.

Description: $this->html(description)

'], + ['body' => '

Ticket "Test UserRequest" created.

Description:

Multi-line
description

'], + ], + 'simple-body-with-placeholder_and_template' => [ + 'EN US', + ['body' => '

Ticket "$this->title$" created.

', 'html_template' => true], + ['body' => +<< +
Formatted eMail
\$content\$
+ + + +HTML + ], + ], + ]; + } + + /** + * @dataProvider doCheckToWriteProvider + * @param string $sBody + * @param string $sHtmlTemplate + * @param string[] $aExpectedWarnings + */ + public function testDoCheckToWrite(string $sBody, ?string $sHtmlTemplate, $expectedWarnings) + { + $oActionEmail = new ActionEmail(); + // Set mandatory fields + $oActionEmail->Set('name', 'test'); + $oActionEmail->Set('subject', 'Ga Bu Zo Meu'); + // Set the fields for testing + $oActionEmail->Set('body', $sBody); + if ($sHtmlTemplate !== null) { + $oDoc = new \ormDocument($sHtmlTemplate, 'text/html', 'template.html'); + $oActionEmail->Set('html_template', $oDoc); + } + $oActionEmail->DoCheckToWrite(); + $aWarnings = $this->GetNonPublicProperty($oActionEmail, 'm_aCheckWarnings'); + if ($expectedWarnings === null) { + $this->assertEquals($aWarnings, $expectedWarnings); + } else { + // The warning messages are localized, but the provider functions does not + // have access to the Dict class, so let's replace the value given by the + // provider by a statically precomputed and localized message + foreach($expectedWarnings as $index => $sMessageKey) { + $expectedWarnings[$index] = static::$aWarningMessages[$sMessageKey]; + } + $this->assertEquals($aWarnings, $expectedWarnings); + } + } + + public function doCheckToWriteProvider() + { + return [ + 'no warnings' => [ + '

Some text here

', + '
$content$
', + null + ], + '$content$ missing' => [ + '

Some text here

', + '
no placeholder
', + [ 'warning-missing-content' ] + ], + ]; + } } \ No newline at end of file
Formatted eMail

Ticket "Test UserRequest" created.