Compare commits

...

12 Commits

Author SHA1 Message Date
Molkobain
5ac675a587 Merge remote-tracking branch 'origin/support/3.2' into develop 2026-05-06 14:53:01 +02:00
Molkobain
769afb2715 Merge remote-tracking branch 'origin/support/3.2.3' into support/3.2 2026-05-06 14:52:10 +02:00
Molkobain
b529a61bc5 Fix PHP code styles 2026-05-06 13:50:58 +02:00
v-dumas
c501280f53 N°7771 - Align Dataflow option with product-itop installation.xml 2026-05-06 11:55:25 +02:00
Vincent Dumas
e662370c32 N°7771 New module for DataFlow management (#905) 2026-05-06 11:28:21 +02:00
Molkobain
3c39c6d8d1 Merge remote-tracking branch 'origin/support/3.2' into develop 2026-05-06 11:19:30 +02:00
Molkobain
a7d0262b21 Merge remote-tracking branch 'origin/support/3.2.3' into support/3.2 2026-05-06 11:18:36 +02:00
Molkobain
c56617abf5 N°8579 - Update PHPDoc 2026-05-06 10:55:39 +02:00
v-dumas
ff94639a61 N°9471 - Rack can contain Enclosure 2026-05-05 10:46:34 +02:00
Molkobain
7676115725 N°9584 - Fix "Unable to connect with STARTTLS" error when sending emails (#901)
* N°9584 - Fix "Unable to connect with STARTTLS" error when sending emails

* N°9584 - Refactor EMailSymfony transport to add unit tests
2026-05-05 09:51:09 +02:00
Molkobain
43ceaeb5a3 Merge remote-tracking branch 'origin/support/3.2.3' into support/3.2 2026-05-04 16:42:30 +02:00
Håkon Harnes
7cac280b83 N°9574 - Fix CKEditor CSS displayed as part of the email message in Gmail (#898)
* fix(email): generate plain text before inlining HTML CSS

* N°9574 - Add unit tests

* Apply suggestions from code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

---------

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>
2026-05-04 16:33:38 +02:00
11 changed files with 1299 additions and 19 deletions

View File

@@ -54,6 +54,15 @@
</modules>
<default>true</default>
</choice>
<choice>
<extension_code>itop-flow-map</extension_code>
<title>Application Data Flows</title>
<description>Modelize flows between your applications, with impacts analysis</description>
<modules type="array">
<module>itop-flow-map</module>
</modules>
<default>true</default>
</choice>
<choice>
<extension_code>itop-config-mgmt-storage</extension_code>
<title>Storage Devices</title>
@@ -80,11 +89,20 @@
<modules type="array">
<module>itop-container-mgmt</module>
</modules>
<default>false</default>
<default>true</default>
</choice>
</options>
</sub_options>
</choice>
<choice>
<extension_code>itop-flow-map</extension_code>
<title>Data flow</title>
<description>Map data flows between applications</description>
<modules type="array">
<module>itop-flow-map</module>
</modules>
<default>false</default>
</choice>
</options>
</step>
<step>

View File

@@ -167,7 +167,7 @@ Dict::Add('EN US', 'English', 'English', [
Dict::Add('EN US', 'English', 'English', [
'Class:Rack' => 'Rack',
'Class:Rack+' => 'A physical cabinet for Datacenter Devices and Chassis.',
'Class:Rack+' => 'A physical cabinet for Datacenter Devices and Enclosures.',
'Class:Rack/ComplementaryName' => '%1$s - %2$s',
'Class:Rack/Attribute:nb_u' => 'Rack units',
'Class:Rack/Attribute:nb_u+' => '',

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<Set>
<DataFlowType alias="DataFlowType" id="1">
<name>http</name>
</DataFlowType>
<DataFlowType alias="DataFlowType" id="2">
<name>https</name>
</DataFlowType>
<DataFlowType alias="DataFlowType" id="3">
<name>ftp</name>
</DataFlowType>
<DataFlowType alias="DataFlowType" id="4">
<name>sftp</name>
</DataFlowType>
<DataFlowType alias="DataFlowType" id="5">
<name>AS2</name>
</DataFlowType>
<DataFlowType alias="DataFlowType" id="6">
<name>X.400</name>
</DataFlowType>
</Set>

View File

@@ -0,0 +1,750 @@
<?xml version="1.0" encoding="UTF-8"?>
<itop_design xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="3.3">
<classes>
<class id="DataFlow" _delta="define">
<parent>cmdbAbstractObject</parent>
<properties>
<category>bizmodel,searchable</category>
<abstract>false</abstract>
<db_table>dataflow</db_table>
<style>
<icon>images/icons8-sorting-arrows-horizontal.svg</icon>
</style>
<naming>
<attributes>
<attribute id="name"/>
</attributes>
</naming>
<reconciliation>
<attributes>
<attribute id="name"/>
<attribute id="destination_id"/>
<attribute id="org_id"/>
<attribute id="source_id"/>
<attribute id="dataflowtype_id"/>
</attributes>
</reconciliation>
<obsolescence>
<condition>status='inactive'</condition>
</obsolescence>
<fields_semantic>
<state_attribute>status</state_attribute>
</fields_semantic>
</properties>
<fields>
<field id="name" xsi:type="AttributeString">
<sql>name</sql>
<default_value/>
<is_null_allowed>false</is_null_allowed>
</field>
<field id="org_id" xsi:type="AttributeExternalKey">
<sql>org_id</sql>
<filter/>
<dependencies/>
<is_null_allowed>false</is_null_allowed>
<target_class>Organization</target_class>
<on_target_delete>DEL_MANUAL</on_target_delete>
<tracking_level>all</tracking_level>
</field>
<field id="source_id" xsi:type="AttributeExternalKey">
<sql>source_id</sql>
<filter/>
<dependencies/>
<is_null_allowed>false</is_null_allowed>
<target_class>FunctionalCI</target_class>
<on_target_delete>DEL_MANUAL</on_target_delete>
<tracking_level>all</tracking_level>
</field>
<field id="source_impact" xsi:type="AttributeEnum">
<sort_type>rank</sort_type>
<values>
<value id="yes">
<code>yes</code>
<rank>10</rank>
</value>
<value id="no">
<code>no</code>
<rank>20</rank>
</value>
</values>
<sql>source_impact</sql>
<default_value>yes</default_value>
<is_null_allowed>false</is_null_allowed>
<display_style>radio_horizontal</display_style>
</field>
<field id="destination_id" xsi:type="AttributeExternalKey">
<sql>destination_id</sql>
<filter/>
<dependencies/>
<is_null_allowed>false</is_null_allowed>
<target_class>FunctionalCI</target_class>
<on_target_delete>DEL_MANUAL</on_target_delete>
<tracking_level>all</tracking_level>
</field>
<field id="destination_impact" xsi:type="AttributeEnum">
<sort_type>rank</sort_type>
<values>
<value id="yes">
<code>yes</code>
<rank>10</rank>
</value>
<value id="no">
<code>no</code>
<rank>20</rank>
</value>
</values>
<sql>destination_impact</sql>
<default_value>no</default_value>
<is_null_allowed>false</is_null_allowed>
<display_style>radio_horizontal</display_style>
</field>
<field id="dataflowtype_id" xsi:type="AttributeExternalKey">
<sql>dataflowtype_id</sql>
<filter/>
<dependencies/>
<is_null_allowed>true</is_null_allowed>
<target_class>DataFlowType</target_class>
<on_target_delete>DEL_MANUAL</on_target_delete>
<tracking_level>all</tracking_level>
</field>
<field id="description" xsi:type="AttributeHTML">
<sql>description</sql>
<default_value/>
<is_null_allowed>true</is_null_allowed>
<tracking_level>all</tracking_level>
</field>
<field id="status" xsi:type="AttributeEnum">
<sql>status</sql>
<values>
<value id="active">
<code>active</code>
<rank>10</rank>
<style>
<main_color>$ibo-lifecycle-active-state-primary-color</main_color>
<complementary_color>$ibo-lifecycle-active-state-secondary-color</complementary_color>
<decoration_classes/>
</style>
</value>
<value id="inactive">
<code>inactive</code>
<rank>20</rank>
<style>
<main_color>$ibo-lifecycle-inactive-state-primary-color</main_color>
<complementary_color>$ibo-lifecycle-inactive-state-secondary-color</complementary_color>
<decoration_classes/>
</style>
</value>
</values>
<sort_type>label</sort_type>
<default_value>active</default_value>
<is_null_allowed>false</is_null_allowed>
<display_style>list</display_style>
<tracking_level>all</tracking_level>
</field>
<field id="business_criticity" xsi:type="AttributeEnum">
<sort_type>rank</sort_type>
<values>
<value id="high">
<code>high</code>
<rank>10</rank>
</value>
<value id="medium">
<code>medium</code>
<rank>20</rank>
</value>
<value id="low">
<code>low</code>
<rank>30</rank>
</value>
</values>
<sql>business_criticity</sql>
<default_value>low</default_value>
<is_null_allowed>false</is_null_allowed>
<display_style>list</display_style>
</field>
<field id="execution_frequency" xsi:type="AttributeEnum">
<sort_type>rank</sort_type>
<values>
<value id="realtime">
<code>realtime</code>
<rank>10</rank>
</value>
<value id="ondemand">
<code>ondemand</code>
<rank>20</rank>
</value>
<value id="hourly">
<code>hourly</code>
<rank>30</rank>
</value>
<value id="daily">
<code>daily</code>
<rank>40</rank>
</value>
<value id="weekly">
<code>weekly</code>
<rank>50</rank>
</value>
<value id="monthly">
<code>monthly</code>
<rank>60</rank>
</value>
<value id="yearly">
<code>yearly</code>
<rank>70</rank>
</value>
</values>
<sql>execution_frequency</sql>
<default_value>daily</default_value>
<is_null_allowed>false</is_null_allowed>
<display_style>list</display_style>
</field>
<field id="contacts_list" xsi:type="AttributeLinkedSetIndirect">
<linked_class>lnkContactToDataFlow</linked_class>
<ext_key_to_me>dataflow_id</ext_key_to_me>
<count_min>0</count_min>
<count_max>0</count_max>
<ext_key_to_remote>contact_id</ext_key_to_remote>
<duplicates/>
</field>
<field id="documents_list" xsi:type="AttributeLinkedSetIndirect">
<linked_class>lnkDocumentToDataFlow</linked_class>
<ext_key_to_me>dataflow_id</ext_key_to_me>
<count_min>0</count_min>
<count_max>0</count_max>
<ext_key_to_remote>document_id</ext_key_to_remote>
<duplicates/>
</field>
</fields>
<methods/>
<presentation>
<list>
<items>
<item id="name">
<rank>10</rank>
</item>
<item id="source_id">
<rank>20</rank>
</item>
<item id="destination_id">
<rank>30</rank>
</item>
<item id="dataflowtype_id">
<rank>40</rank>
</item>
<item id="business_criticity">
<rank>50</rank>
</item>
</items>
</list>
<search>
<items>
<item id="org_id">
<rank>10</rank>
</item>
<item id="source_id">
<rank>20</rank>
</item>
<item id="destination_id">
<rank>30</rank>
</item>
<item id="status">
<rank>40</rank>
</item>
</items>
</search>
<details>
<items>
<item id="col:col1">
<items>
<item id="fieldset:DataFlow:baseinfo">
<items>
<item id="name">
<rank>10</rank>
</item>
<item id="org_id">
<rank>20</rank>
</item>
<item id="status">
<rank>30</rank>
</item>
<item id="business_criticity">
<rank>40</rank>
</item>
</items>
<rank>10</rank>
</item>
<item id="fieldset:DataFlow:moreinfo">
<items>
<item id="source_id">
<rank>10</rank>
</item>
<item id="source_impact">
<rank>20</rank>
</item>
<item id="destination_id">
<rank>30</rank>
</item>
<item id="destination_impact">
<rank>40</rank>
</item>
<item id="dataflowtype_id">
<rank>50</rank>
</item>
<item id="execution_frequency">
<rank>60</rank>
</item>
</items>
<rank>20</rank>
</item>
</items>
<rank>10</rank>
</item>
<item id="col:col2">
<items>
<item id="fieldset:DataFlow:otherinfo">
<items>
<item id="description">
<rank>10</rank>
</item>
</items>
<rank>10</rank>
</item>
</items>
<rank>20</rank>
</item>
<item id="contacts_list">
<rank>70</rank>
</item>
<item id="documents_list">
<rank>80</rank>
</item>
</items>
</details>
<default_search>
<items>
<item id="org_id">
<rank>10</rank>
</item>
<item id="source_id">
<rank>20</rank>
</item>
<item id="destination_id">
<rank>30</rank>
</item>
<item id="dataflowtype_id">
<rank>40</rank>
</item>
<item id="status">
<rank>50</rank>
</item>
</items>
</default_search>
<summary>
<items>
<item id="org_id">
<rank>10</rank>
</item>
<item id="description">
<rank>20</rank>
</item>
</items>
</summary>
</presentation>
<relations>
<relation id="impacts">
<neighbours>
<neighbour id="functionalci ">
<query_down><![CDATA[SELECT FunctionalCI WHERE :this->destination_impact = 'yes' AND id = :this->destination_id]]></query_down>
<query_up><![CDATA[SELECT DataFlow AS f JOIN FunctionalCI AS ci ON f.destination_id = ci.id WHERE f.destination_impact = 'yes' AND ci.id=:this->id]]></query_up>
<direction>both</direction>
</neighbour>
<neighbour id="contact ">
<attribute>contacts_list</attribute>
<direction>down</direction>
</neighbour>
</neighbours>
</relation>
</relations>
</class>
<class id="lnkDocumentToDataFlow" _delta="define">
<parent>cmdbAbstractObject</parent>
<properties>
<is_link>1</is_link>
<category>bizmodel</category>
<abstract>false</abstract>
<key_type>autoincrement</key_type>
<db_table>lnkdocumenttodataflow</db_table>
<db_key_field>id</db_key_field>
<db_final_class_field/>
<naming>
<attributes>
<attribute id="document_id_friendlyname"/>
<attribute id="dataflow_id_friendlyname"/>
</attributes>
</naming>
<style>
<icon/>
</style>
<reconciliation>
<attributes>
<attribute id="dataflow_id"/>
<attribute id="document_id"/>
</attributes>
</reconciliation>
<uniqueness_rules>
<rule id="no_duplicate">
<attributes>
<attribute id="document_id"/>
<attribute id="dataflow_id"/>
</attributes>
<filter><![CDATA[]]></filter>
<disabled>false</disabled>
<is_blocking>true</is_blocking>
</rule>
</uniqueness_rules>
</properties>
<fields>
<field id="dataflow_id" xsi:type="AttributeExternalKey">
<sql>dataflow_id</sql>
<target_class>DataFlow</target_class>
<is_null_allowed>false</is_null_allowed>
<on_target_delete>DEL_AUTO</on_target_delete>
</field>
<field id="document_id" xsi:type="AttributeExternalKey">
<sql>document_id</sql>
<target_class>Document</target_class>
<is_null_allowed>false</is_null_allowed>
<on_target_delete>DEL_AUTO</on_target_delete>
</field>
</fields>
<methods/>
<presentation>
<details>
<items>
<item id="document_id">
<rank>10</rank>
</item>
<item id="dataflow_id">
<rank>20</rank>
</item>
</items>
</details>
<search>
<items>
<item id="dataflow_id">
<rank>10</rank>
</item>
<item id="document_id">
<rank>20</rank>
</item>
</items>
</search>
<list>
<items>
<item id="dataflow_id">
<rank>10</rank>
</item>
<item id="document_id">
<rank>20</rank>
</item>
</items>
</list>
</presentation>
</class>
<class id="lnkContactToDataFlow" _delta="define">
<parent>cmdbAbstractObject</parent>
<properties>
<is_link>1</is_link>
<category>bizmodel</category>
<abstract>false</abstract>
<key_type>autoincrement</key_type>
<db_table>lnkcontacttodataflow</db_table>
<db_key_field>id</db_key_field>
<db_final_class_field/>
<naming>
<attributes>
<attribute id="contact_id_friendlyname"/>
<attribute id="dataflow_id_friendlyname"/>
</attributes>
</naming>
<style>
<icon/>
</style>
<reconciliation>
<attributes>
<attribute id="dataflow_id"/>
<attribute id="contact_id"/>
</attributes>
</reconciliation>
<uniqueness_rules>
<rule id="no_duplicate">
<attributes>
<attribute id="contact_id"/>
<attribute id="dataflow_id"/>
</attributes>
<filter><![CDATA[]]></filter>
<disabled>false</disabled>
<is_blocking>true</is_blocking>
</rule>
</uniqueness_rules>
</properties>
<fields>
<field id="dataflow_id" xsi:type="AttributeExternalKey">
<sql>dataflow_id</sql>
<target_class>DataFlow</target_class>
<is_null_allowed>false</is_null_allowed>
<on_target_delete>DEL_AUTO</on_target_delete>
</field>
<field id="contact_id" xsi:type="AttributeExternalKey">
<sql>contact_id</sql>
<target_class>Contact</target_class>
<is_null_allowed>false</is_null_allowed>
<on_target_delete>DEL_AUTO</on_target_delete>
</field>
</fields>
<methods/>
<presentation>
<details>
<items>
<item id="contact_id">
<rank>10</rank>
</item>
<item id="dataflow_id">
<rank>20</rank>
</item>
</items>
</details>
<search>
<items>
<item id="dataflow_id">
<rank>10</rank>
</item>
<item id="contact_id">
<rank>20</rank>
</item>
</items>
</search>
<list>
<items>
<item id="dataflow_id">
<rank>10</rank>
</item>
<item id="contact_id">
<rank>20</rank>
</item>
</items>
</list>
</presentation>
</class>
<class id="DataFlowType" _delta="define">
<parent>Typology</parent>
<properties>
<category>bizmodel,searchable</category>
<abstract>false</abstract>
<db_table>dataflowtype</db_table>
<naming>
<attributes>
<attribute id="name"/>
</attributes>
</naming>
<reconciliation>
<attributes>
<attribute id="name"/>
<attribute id="finalclass"/>
</attributes>
</reconciliation>
</properties>
<fields/>
<methods/>
<presentation>
<list>
<items>
<item id="finalclass">
<rank>10</rank>
</item>
</items>
</list>
<search>
<items>
<item id="name">
<rank>10</rank>
</item>
</items>
</search>
<details>
<items>
<item id="name">
<rank>10</rank>
</item>
</items>
</details>
</presentation>
</class>
<class id="FunctionalCI" _delta="must_exist">
<fields>
<field id="dataflows" xsi:type="AttributeDashboard" _delta="define">
<is_user_editable>true</is_user_editable>
<definition>
<layout>DashboardLayoutTwoCols</layout>
<title>FunctionalCI:DataFlow:Title</title>
<auto_reload>
<enabled>false</enabled>
<interval>300</interval>
</auto_reload>
<cells>
<cell id="0">
<rank>0</rank>
<dashlets>
<dashlet id="DataFlow_Inbound" xsi:type="DashletObjectList">
<rank>0</rank>
<title>FunctionalCI:DataFlow:Inbound</title>
<query>SELECT DataFlow WHERE destination_id=:this->id</query>
<menu>true</menu>
</dashlet>
</dashlets>
</cell>
<cell id="1">
<rank>1</rank>
<dashlets>
<dashlet id="DataFlow_Outbound" xsi:type="DashletObjectList">
<rank>0</rank>
<title>FunctionalCI:DataFlow:Outbound</title>
<query>SELECT DataFlow WHERE source_id=:this->id</query>
<menu>true</menu>
</dashlet>
</dashlets>
</cell>
</cells>
</definition>
</field>
</fields>
<relations>
<relation id="impacts">
<neighbours>
<neighbour id="flow" _delta="define">
<query_down><![CDATA[SELECT DataFlow WHERE source_id = :this->id AND source_impact = 'yes']]></query_down>
<query_up><![CDATA[SELECT FunctionalCI AS ci JOIN DataFlow AS f ON f.source_id = ci.id WHERE f.source_impact = 'yes' AND f.id = :this->id]]></query_up>
<direction>both</direction>
</neighbour>
</neighbours>
</relation>
</relations>
</class>
<class id="ApplicationSolution" _delta="must_exist">
<presentation>
<details>
<items>
<item id="dataflows" _delta="define">
<rank>25</rank>
</item>
</items>
</details>
</presentation>
</class>
<class id="DatabaseSchema" _delta="must_exist">
<presentation>
<details>
<items>
<item id="dataflows" _delta="define">
<rank>25</rank>
</item>
</items>
</details>
</presentation>
</class>
<class id="DBServer" _delta="must_exist">
<presentation>
<details>
<items>
<item id="dataflows" _delta="define">
<rank>25</rank>
</item>
</items>
</details>
</presentation>
</class>
<class id="Middleware" _delta="must_exist">
<presentation>
<details>
<items>
<item id="dataflows" _delta="define">
<rank>25</rank>
</item>
</items>
</details>
</presentation>
</class>
<class id="MiddlewareInstance" _delta="must_exist">
<presentation>
<details>
<items>
<item id="dataflows" _delta="define">
<rank>25</rank>
</item>
</items>
</details>
</presentation>
</class>
<class id="WebApplication" _delta="must_exist">
<presentation>
<details>
<items>
<item id="dataflows" _delta="define">
<rank>25</rank>
</item>
</items>
</details>
</presentation>
</class>
<class id="WebServer" _delta="must_exist">
<presentation>
<details>
<items>
<item id="dataflows" _delta="define">
<rank>25</rank>
</item>
</items>
</details>
</presentation>
</class>
<class id="OtherSoftware" _delta="must_exist">
<presentation>
<details>
<items>
<item id="dataflows" _delta="define">
<rank>25</rank>
</item>
</items>
</details>
</presentation>
</class>
</classes>
<menus>
<menu id="ConfigManagementOverview" xsi:type="DashboardMenuNode" _delta="must_exist">
<definition>
<cells>
<cell id="3" delta="if_exists">
<dashlets>
<dashlet id="DataFlow_20" xsi:type="DashletBadge" _delta="define">
<rank>20</rank>
<class>DataFlow</class>
</dashlet>
</dashlets>
</cell>
</cells>
</definition>
</menu>
</menus>
<user_rights>
<groups>
<group id="Configuration">
<classes>
<class id="DataFlow" _delta="define_if_not_exists"/>
</classes>
</group>
</groups>
<profiles>
</profiles>
</user_rights>
</itop_design>

View File

@@ -0,0 +1,96 @@
<?php
/**
* Module combodo-flow-map
*
* @copyright Copyright (C) 2013 XXXXX
* @license http://opensource.org/licenses/AGPL-3.0
*/
Dict::Add('EN US', 'English', 'English', [
'Class:FunctionalCI/Attribute:dataflows' => 'Data flows',
'Class:FunctionalCI/Attribute:dataflows+' => 'Data flows for which this object is the source or the destination',
'FunctionalCI:DataFlow:Title' => 'Data flows',
'FunctionalCI:DataFlow:Inbound' => 'Inbound flows',
'FunctionalCI:DataFlow:Outbound' => 'Outbound flows',
'DataFlow:baseinfo' => 'General information',
'DataFlow:otherinfo' => 'Other information',
'DataFlow:moreinfo' => 'Flow specifics',
'Class:DataFlow' => 'Flow',
'Class:DataFlow+' => 'For application flow for example',
'Class:DataFlow/Name' => '%1$s',
'Class:DataFlow/Attribute:name' => 'Name',
'Class:DataFlow/Attribute:name_id+' => 'Data that are transferred',
'Class:DataFlow/Attribute:source_id' => 'Source',
'Class:DataFlow/Attribute:source_id+' => 'Source Ci of the flow',
'Class:DataFlow/Attribute:source_impact' => 'Source impacts?',
'Class:DataFlow/Attribute:source_impact+' => 'Does the source impact the flow?',
'Class:DataFlow/Attribute:source_impact/Value:yes' => 'yes',
'Class:DataFlow/Attribute:source_impact/Value:yes+' => 'If the source falls down, the flow is impacted',
'Class:DataFlow/Attribute:source_impact/Value:no' => 'no',
'Class:DataFlow/Attribute:source_impact/Value:no+' => 'If the source falls down, the flow is not impacted',
'Class:DataFlow/Attribute:destination_id' => 'Destination',
'Class:DataFlow/Attribute:destination_id+' => 'Destination Ci for the flow',
'Class:DataFlow/Attribute:destination_impact' => 'Destination impacted',
'Class:DataFlow/Attribute:destination_impact+' => 'Is the destination impacted by the flow ?',
'Class:DataFlow/Attribute:destination_impact/Value:yes' => 'yes',
'Class:DataFlow/Attribute:destination_impact/Value:yes+' => 'If the flow stops, the destination is impacted',
'Class:DataFlow/Attribute:destination_impact/Value:no' => 'no',
'Class:DataFlow/Attribute:destination_impact/Value:no+' => 'If the flow stops, the destination is not impacted',
'Class:DataFlow/Attribute:dataflowtype_id' => 'Flow type',
'Class:DataFlow/Attribute:dataflowtype_id+' => 'Typology of Flow.',
'Class:DataFlow/Attribute:description' => 'Description',
'Class:DataFlow/Attribute:description+' => '',
'Class:DataFlow/Attribute:status' => 'Status',
'Class:DataFlow/Attribute:status+' => '',
'Class:DataFlow/Attribute:status/Value:active' => 'active',
'Class:DataFlow/Attribute:status/Value:inactive' => 'inactive',
'Class:DataFlow/Attribute:org_id' => 'Organization',
'Class:DataFlow/Attribute:org_id+' => '',
'Class:DataFlow/Attribute:business_criticity' => 'Business criticality',
'Class:DataFlow/Attribute:business_criticity+' => '',
'Class:DataFlow/Attribute:business_criticity/Value:high' => 'high',
'Class:DataFlow/Attribute:business_criticity/Value:high+' => '',
'Class:DataFlow/Attribute:business_criticity/Value:low' => 'low',
'Class:DataFlow/Attribute:business_criticity/Value:low+' => '',
'Class:DataFlow/Attribute:business_criticity/Value:medium' => 'medium',
'Class:DataFlow/Attribute:business_criticity/Value:medium+' => '',
'Class:DataFlow/Attribute:execution_frequency' => 'Execution frequency',
'Class:DataFlow/Attribute:execution_frequency+' => 'How often the data flow is executed',
'Class:DataFlow/Attribute:execution_frequency/Value:realtime' => 'real-time',
'Class:DataFlow/Attribute:execution_frequency/Value:realtime+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:ondemand' => 'on demand',
'Class:DataFlow/Attribute:execution_frequency/Value:ondemand+' => 'on the fly, not scheduled',
'Class:DataFlow/Attribute:execution_frequency/Value:hourly' => 'hourly',
'Class:DataFlow/Attribute:execution_frequency/Value:hourly+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:daily' => 'daily',
'Class:DataFlow/Attribute:execution_frequency/Value:daily+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:weekly' => 'weekly',
'Class:DataFlow/Attribute:execution_frequency/Value:weekly+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:monthly' => 'monthly',
'Class:DataFlow/Attribute:execution_frequency/Value:monthly+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:yearly' => 'yearly',
'Class:DataFlow/Attribute:execution_frequency/Value:yearly+' => '',
'Class:DataFlow/Attribute:documents_list' => 'Documents',
'Class:DataFlow/Attribute:documents_list+' => 'Eg: technical specifications, runbooks, etc.',
'Class:DataFlow/Attribute:contacts_list' => 'Contacts',
'Class:DataFlow/Attribute:contacts_list+' => 'Eg: flow owner, technical support, etc.',
/*
'Class:DataFlow/Attribute:source_id_friendlyname' => 'source_id_friendlyname',
'Class:DataFlow/Attribute:source_id_friendlyname+' => 'Full name',
'Class:DataFlow/Attribute:source_id_finalclass_recall' => 'source_id->CI sub-class',
'Class:DataFlow/Attribute:source_id_finalclass_recall+' => 'Name of the final class',
'Class:DataFlow/Attribute:source_id_obsolescence_flag' => 'source_id->Obsolete',
'Class:DataFlow/Attribute:source_id_obsolescence_flag+' => 'Computed dynamically on other attributes',
'Class:DataFlow/Attribute:destination_id_friendlyname' => 'destination_id_friendlyname',
'Class:DataFlow/Attribute:destination_id_friendlyname+' => 'Full name',
'Class:DataFlow/Attribute:destination_id_finalclass_recall' => 'destination_id->CI sub-class',
'Class:DataFlow/Attribute:destination_id_finalclass_recall+' => 'Name of the final class',
'Class:DataFlow/Attribute:destination_id_obsolescence_flag' => 'destination_id->Obsolete',
'Class:DataFlow/Attribute:destination_id_obsolescence_flag+' => 'Computed dynamically on other attributes',
*/
]);

View File

@@ -0,0 +1,96 @@
<?php
/**
* Module combodo-flow-map
*
* @copyright Copyright (C) 2013 XXXXX
* @license http://opensource.org/licenses/AGPL-3.0
*/
Dict::Add('FR FR', 'French', 'Français', [
'Class:FunctionalCI/Attribute:dataflows' => 'Flux de données',
'Class:FunctionalCI/Attribute:dataflows+' => 'Flux de données dont cet objet est la source ou la destination',
'FunctionalCI:DataFlow:Title' => 'Flux de données',
'FunctionalCI:DataFlow:Inbound' => 'Flux entrants',
'FunctionalCI:DataFlow:Outbound' => 'Flux sortants',
'DataFlow:baseinfo' => 'Informations générales',
'DataFlow:otherinfo' => 'Autres informations',
'DataFlow:moreinfo' => 'Spécificités du flux',
'Class:DataFlow' => 'Flux de Données',
'Class:DataFlow+' => 'Modélise les données transférées entre instances d\'application',
'Class:DataFlow/Name' => '%1$s',
'Class:DataFlow/Attribute:name' => 'Nom',
'Class:DataFlow/Attribute:name_id+' => 'Type de données transferées',
'Class:DataFlow/Attribute:source_id' => 'Source',
'Class:DataFlow/Attribute:source_id+' => 'Instance d\application à la source du flux de données',
'Class:DataFlow/Attribute:source_impact' => 'Source impactante ?',
'Class:DataFlow/Attribute:source_impact+' => 'La source impacte-t-elle le flux de données ?',
'Class:DataFlow/Attribute:source_impact/Value:yes' => 'oui',
'Class:DataFlow/Attribute:source_impact/Value:yes+' => 'Si la source tombe en panne, le flux de données est impacté',
'Class:DataFlow/Attribute:source_impact/Value:no' => 'non',
'Class:DataFlow/Attribute:source_impact/Value:no+' => 'Si la source tombe en panne, le flux de données n\'est pas impacté',
'Class:DataFlow/Attribute:destination_id' => 'Destinataire',
'Class:DataFlow/Attribute:destination_id+' => 'Destinataire des données, à choisir parmi les instances d\'application',
'Class:DataFlow/Attribute:destination_impact' => 'Destinataire impacté ?',
'Class:DataFlow/Attribute:destination_impact+' => 'Le destinataire est-il impacté si le flux de données s\'arrête ?',
'Class:DataFlow/Attribute:destination_impact/Value:yes' => 'oui',
'Class:DataFlow/Attribute:destination_impact/Value:yes+' => 'Si le flux s\'arrête, le destinataire est impacté',
'Class:DataFlow/Attribute:destination_impact/Value:no' => 'non',
'Class:DataFlow/Attribute:destination_impact/Value:no+' => 'Si le flux s\'arrête, le destinataire n\'est pas impacté',
'Class:DataFlow/Attribute:dataflowtype_id' => 'Type de flux',
'Class:DataFlow/Attribute:dataflowtype_id+' => 'Typologie du flux',
'Class:DataFlow/Attribute:description' => 'Description',
'Class:DataFlow/Attribute:description+' => '',
'Class:DataFlow/Attribute:status' => 'Etat',
'Class:DataFlow/Attribute:status+' => '',
'Class:DataFlow/Attribute:status/Value:active' => 'actif',
'Class:DataFlow/Attribute:status/Value:inactive' => 'inactif',
'Class:DataFlow/Attribute:org_id' => 'Organisation',
'Class:DataFlow/Attribute:org_id+' => '',
'Class:DataFlow/Attribute:business_criticity' => 'Criticité',
'Class:DataFlow/Attribute:business_criticity+' => '',
'Class:DataFlow/Attribute:business_criticity/Value:high' => 'haute',
'Class:DataFlow/Attribute:business_criticity/Value:high+' => '',
'Class:DataFlow/Attribute:business_criticity/Value:low' => 'basse',
'Class:DataFlow/Attribute:business_criticity/Value:low+' => '',
'Class:DataFlow/Attribute:business_criticity/Value:medium' => 'moyenne',
'Class:DataFlow/Attribute:business_criticity/Value:medium+' => '',
'Class:DataFlow/Attribute:execution_frequency' => 'Fréquence d\'exécution',
'Class:DataFlow/Attribute:execution_frequency+' => 'À quelle fréquence le transfert de données est-il exécuté',
'Class:DataFlow/Attribute:execution_frequency/Value:realtime' => 'temps réel',
'Class:DataFlow/Attribute:execution_frequency/Value:realtime+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:ondemand' => 'à la demande',
'Class:DataFlow/Attribute:execution_frequency/Value:ondemand+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:hourly' => 'horaire',
'Class:DataFlow/Attribute:execution_frequency/Value:hourly+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:daily' => 'journalière',
'Class:DataFlow/Attribute:execution_frequency/Value:daily+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:weekly' => 'hebdomadaire',
'Class:DataFlow/Attribute:execution_frequency/Value:weekly+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:monthly' => 'mensuelle',
'Class:DataFlow/Attribute:execution_frequency/Value:monthly+' => '',
'Class:DataFlow/Attribute:execution_frequency/Value:yearly' => 'annuelle',
'Class:DataFlow/Attribute:execution_frequency/Value:yearly+' => '',
'Class:DataFlow/Attribute:documents_list' => 'Documents',
'Class:DataFlow/Attribute:documents_list+' => 'Eg: technical specifications, runbooks, etc.',
'Class:DataFlow/Attribute:contacts_list' => 'Contacts',
'Class:DataFlow/Attribute:contacts_list+' => 'Eg: flow owner, technical support, etc.',
/*
'Class:DataFlow/Attribute:source_id_friendlyname' => 'source_id_friendlyname',
'Class:DataFlow/Attribute:source_id_friendlyname+' => 'Nom complet',
'Class:DataFlow/Attribute:source_id_finalclass_recall' => 'source_id->CI sub-class',
'Class:DataFlow/Attribute:source_id_finalclass_recall+' => 'Classe finale',
'Class:DataFlow/Attribute:source_id_obsolescence_flag' => 'source_id->Obsolete',
'Class:DataFlow/Attribute:source_id_obsolescence_flag+' => 'Computed dynamically on other attributes',
'Class:DataFlow/Attribute:destination_id_friendlyname' => 'destination_id_friendlyname',
'Class:DataFlow/Attribute:destination_id_friendlyname+' => 'Nom complet',
'Class:DataFlow/Attribute:destination_id_finalclass_recall' => 'destination_id->CI sub-class',
'Class:DataFlow/Attribute:destination_id_finalclass_recall+' => 'Classe finale',
'Class:DataFlow/Attribute:destination_id_obsolescence_flag' => 'destination_id->Obsolete',
'Class:DataFlow/Attribute:destination_id_obsolescence_flag+' => 'Computed dynamically on other attributes',
*/
]);

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><linearGradient id="mv_DwPz_GcV~datTQ_sP3a" x1="27.258" x2="38.501" y1="18.189" y2="44.314" gradientTransform="rotate(90 23.5 24)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32bdef"/><stop offset="1" stop-color="#1ea2e4"/></linearGradient><path fill="url(#mv_DwPz_GcV~datTQ_sP3a)" d="M14,41.19V37h14c0.552,0,1-0.448,1-1v-4c0-0.552-0.448-1-1-1H14v-4.19 c0-0.72-0.87-1.08-1.379-0.571L5.92,32.939c-0.586,0.586-0.586,1.536,0,2.121l6.701,6.701C13.13,42.271,14,41.91,14,41.19z"/><linearGradient id="mv_DwPz_GcV~datTQ_sP3b" x1="32.674" x2="34.456" y1="9.581" y2="13.722" gradientTransform="rotate(90 23.5 24)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32bdef"/><stop offset="1" stop-color="#1ea2e4"/></linearGradient><path fill="url(#mv_DwPz_GcV~datTQ_sP3b)" d="M35,36v-4c0-0.552,0.448-1,1-1l0,0c0.552,0,1,0.448,1,1v4c0,0.552-0.448,1-1,1l0,0 C35.448,37,35,36.552,35,36z"/><linearGradient id="mv_DwPz_GcV~datTQ_sP3c" x1="32.674" x2="34.456" y1="5.581" y2="9.722" gradientTransform="rotate(90 23.5 24)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32bdef"/><stop offset="1" stop-color="#1ea2e4"/></linearGradient><path fill="url(#mv_DwPz_GcV~datTQ_sP3c)" d="M39,36v-4c0-0.552,0.448-1,1-1l0,0c0.552,0,1,0.448,1,1v4c0,0.552-0.448,1-1,1l0,0 C39.448,37,39,36.552,39,36z"/><linearGradient id="mv_DwPz_GcV~datTQ_sP3d" x1="32.674" x2="34.456" y1="13.581" y2="17.722" gradientTransform="rotate(90 23.5 24)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32bdef"/><stop offset="1" stop-color="#1ea2e4"/></linearGradient><path fill="url(#mv_DwPz_GcV~datTQ_sP3d)" d="M31,36v-4c0-0.552,0.448-1,1-1h0c0.552,0,1,0.448,1,1v4c0,0.552-0.448,1-1,1h0 C31.448,37,31,36.552,31,36z"/><linearGradient id="mv_DwPz_GcV~datTQ_sP3e" x1="551.258" x2="562.501" y1="-252.291" y2="-226.167" gradientTransform="rotate(-90 421.24 151.26)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1ea2e4"/><stop offset="1" stop-color="#32bdef"/></linearGradient><path fill="url(#mv_DwPz_GcV~datTQ_sP3e)" d="M33,7.81V12H19c-0.552,0-1,0.448-1,1v4c0,0.552,0.448,1,1,1h14v4.19 c0,0.72,0.87,1.08,1.379,0.571l6.701-6.701c0.586-0.586,0.586-1.536,0-2.121l-6.701-6.701C33.87,6.729,33,7.09,33,7.81z"/><linearGradient id="mv_DwPz_GcV~datTQ_sP3f" x1="556.674" x2="558.456" y1="-260.899" y2="-256.759" gradientTransform="rotate(-90 421.24 151.26)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1ea2e4"/><stop offset="1" stop-color="#32bdef"/></linearGradient><path fill="url(#mv_DwPz_GcV~datTQ_sP3f)" d="M12,13v4c0,0.552-0.448,1-1,1h0c-0.552,0-1-0.448-1-1v-4c0-0.552,0.448-1,1-1h0 C11.552,12,12,12.448,12,13z"/><linearGradient id="mv_DwPz_GcV~datTQ_sP3g" x1="556.674" x2="558.456" y1="-264.899" y2="-260.759" gradientTransform="rotate(-90 421.24 151.26)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1ea2e4"/><stop offset="1" stop-color="#32bdef"/></linearGradient><path fill="url(#mv_DwPz_GcV~datTQ_sP3g)" d="M8,13v4c0,0.552-0.448,1-1,1h0c-0.552,0-1-0.448-1-1v-4c0-0.552,0.448-1,1-1h0 C7.552,12,8,12.448,8,13z"/><linearGradient id="mv_DwPz_GcV~datTQ_sP3h" x1="556.674" x2="558.456" y1="-256.899" y2="-252.758" gradientTransform="rotate(-90 421.24 151.26)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1ea2e4"/><stop offset="1" stop-color="#32bdef"/></linearGradient><path fill="url(#mv_DwPz_GcV~datTQ_sP3h)" d="M16,13v4c0,0.552-0.448,1-1,1h0c-0.552,0-1-0.448-1-1v-4c0-0.552,0.448-1,1-1h0 C15.552,12,16,12.448,16,13z"/></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1,50 @@
<?php
//
// iTop module definition file
//
SetupWebPage::AddModule(
__FILE__, // Path to the current file, all other file names are relative to the directory containing this file
'itop-flow-map/3.3.0',
[
// Identification
//
'label' => 'Applications data flows',
'category' => 'business',
// Setup
//
'dependencies' => [
'itop-config-mgmt/3.2.0',
],
'mandatory' => false,
'visible' => true,
// Components
//
'datamodel' => [
],
'webservice' => [
],
'data.struct' => [
'data/en_us.data.itop-flow-map.xml',
],
'data.sample' => [
// add your sample data XML files here,
],
// Documentation
//
'doc.manual_setup' => '', // hyperlink to manual setup documentation, if any
'doc.more_information' => '', // hyperlink to more information, if any
// Default settings
//
'settings' => [
// Module specific settings go here, if any
],
]
);

View File

@@ -153,8 +153,12 @@ class Extension
return twig_array_filter($oTwigEnv, $array, $arrow);
}, ['needs_environment' => true]);
// @since 3.3.0 N°8579
// Filter to remove spaces between HTML tags, overwrite the deprecated core "spaceless" filter
/**
* Filter to remove spaces between HTML tags, overwrite the deprecated core "spaceless" filter
* Usage in twig: {% apply spaceless %}some html{% endapply %}
*
* @since 3.2.3 3.3.0 N°8579
*/
$aFilters[] = new TwigFilter('spaceless', function (?string $content) {
return trim(preg_replace('/>\s+</', '><', $content ?? ''));
}, ['is_safe' => ['html']]);

View File

@@ -29,7 +29,9 @@ use Symfony\Component\CssSelector\Exception\ParseException;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mime\Email as SymfonyEmail;
use Symfony\Component\Mime\HtmlToTextConverter\DefaultHtmlToTextConverter;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\RelatedPart;
use Symfony\Component\Mime\Part\Multipart\MixedPart;
@@ -183,18 +185,7 @@ class EMailSymfony extends Email
$sDsn = sprintf('smtp://%s%s@%s%s', $sDsnUser, $sDsnPassword, $sDsnPort, $sEncQuery);
}
$oTransport = Transport::fromDsn($sDsn);
// Handle peer verification
$oStream = $oTransport->getStream();
$aOptions = $oStream->getStreamOptions();
if (!$bVerifyPeer && array_key_exists('ssl', $aOptions)) {
// Disable verification
$aOptions['ssl']['verify_peer'] = false;
$aOptions['ssl']['verify_peer_name'] = false;
$aOptions['ssl']['allow_self_signed'] = true;
}
$oStream->setStreamOptions($aOptions);
$oTransport = $this->CreateSmtpTransport($sDsn, $bVerifyPeer);
$oMailer = new Mailer($oTransport);
break;
@@ -260,6 +251,36 @@ class EMailSymfony extends Email
}
}
/**
* Build and configure an SMTP transport from a DSN string.
*
* Extracted from {@see SendSynchronous} to make SSL option handling independently testable.
* When $bVerifyPeer is false, the ssl stream context options must be written unconditionally:
* with STARTTLS the connection starts unencrypted, so the 'ssl' key is absent from the stream
* options at construction time and only used later when stream_socket_enable_crypto() is called.
*
* @param string $sDsn Full Symfony Mailer DSN (smtp:// or smtps://)
* @param bool $bVerifyPeer Whether to verify the peer SSL certificate
*
* @return \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport
*/
protected function CreateSmtpTransport(string $sDsn, bool $bVerifyPeer): EsmtpTransport
{
/** @var EsmtpTransport $oTransport */
$oTransport = Transport::fromDsn($sDsn);
$oStream = $oTransport->getStream();
$aOptions = $oStream->getStreamOptions();
if (!$bVerifyPeer) {
$aOptions['ssl']['verify_peer'] = false;
$aOptions['ssl']['verify_peer_name'] = false;
$aOptions['ssl']['allow_self_signed'] = true;
}
$oStream->setStreamOptions($aOptions);
return $oTransport;
}
/**
* Reprocess the body of the message (if it is an HTML message)
* to replace the URL of images based on attachments by a link
@@ -416,13 +437,13 @@ class EMailSymfony extends Email
$this->m_aData['body'] = ['body' => $sBody, 'mimeType' => $sMimeType];
$oTextPart = new TextPart(strip_tags($sBody), 'utf-8', 'plain', 'base64');
// Embed inline images and store them in attachments (so BuildSymfonyMessageFromInternal can pick them)
if ($sPrimaryMimeType === 'text/html') {
$aAdditionalParts = $this->EmbedInlineImages($sBody);
$oTextPart = new TextPart((new DefaultHtmlToTextConverter())->convert($sBody, 'utf-8'), 'utf-8', 'plain', 'base64');
$oHtmlPart = new TextPart($sBody, 'utf-8', 'html', 'base64');
$oAlternativePart = new AlternativePart($oHtmlPart, $oTextPart);
// It's important de order parts from least prefered to most prefered as per RFC 2046 {@see https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.4}
$oAlternativePart = new AlternativePart($oTextPart, $oHtmlPart);
// Default root part is the HTML body
$oRootPart = $oAlternativePart;

View File

@@ -1,6 +1,12 @@
<?php
use Combodo\iTop\Core\Email\EMailSymfony;
use Combodo\iTop\Test\UnitTest\ItopTestCase;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\AlternativePart;
use Symfony\Component\Mime\Part\Multipart\RelatedPart;
use Symfony\Component\Mime\Part\TextPart;
class EmailSymfonyTest extends ItopTestCase
{
@@ -135,4 +141,221 @@ HTML;
$this->assertSame($sExpectedBody, $sActualBody);
}
/**
* Returns the parts of the AlternativePart produced by SetBody() for an HTML email.
*
* Handles both the simple case (AlternativePart at root) and the inline-images case
* where the root is a RelatedPart whose first child is the AlternativePart.
*
* @return AbstractPart[]
*/
private function GetAlternativePartsFromHtmlEmail(EMailSymfony $oEmail): array
{
$oSymfonyMessage = $this->GetNonPublicProperty($oEmail, 'm_oMessage');
$oBody = $oSymfonyMessage->getBody();
// With inline images the root is a RelatedPart; the AlternativePart is its first child.
if ($oBody instanceof RelatedPart) {
$oBody = $oBody->getParts()[0];
}
$this->assertInstanceOf(AlternativePart::class, $oBody, 'Body should be a multipart/alternative for HTML emails');
return $oBody->getParts();
}
/**
* RFC 2046 §5.1.4: parts in multipart/alternative must be ordered from least to most preferred.
* Email clients display the last part they support, so text/plain must come first and text/html last.
*
* @see https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.4
* @covers \Combodo\iTop\Core\Email\EmailSymfony::SetBody()
* @since N°9574
*/
public function testSetBodyAlternativePartOrderForHtmlEmailIsPlainThenHtml(): void
{
$oEmail = new EMailSymfony();
$oEmail->SetBody('<p>Hello there!</p>', 'text/html');
[$oFirstPart, $oSecondPart] = $this->GetAlternativePartsFromHtmlEmail($oEmail);
$this->assertSame('plain', $oFirstPart->getMediaSubtype(), 'First part must be text/plain (least preferred per RFC 2046)');
$this->assertSame('html', $oSecondPart->getMediaSubtype(), 'Last part must be text/html (most preferred per RFC 2046)');
}
/**
* @dataProvider provideSetBodyPlainTextDoesNotContainCss
*
* @covers \Combodo\iTop\Core\Email\EmailSymfony::SetBody()
* @since N°9574
*/
public function testSetBodyPlainTextDoesNotContainCss(string $sHtml, ?string $sCustomStyles): void
{
$oEmail = new EMailSymfony();
$oEmail->SetBody($sHtml, 'text/html', $sCustomStyles);
// We locate the plain text part by subtype to be order-agnostic and isolate this assertion from the order bug.
$aParts = $this->GetAlternativePartsFromHtmlEmail($oEmail);
$oPlainPart = null;
foreach ($aParts as $oPart) {
if ($oPart instanceof TextPart && $oPart->getMediaSubtype() === 'plain') {
$oPlainPart = $oPart;
break;
}
}
$this->assertNotNull($oPlainPart, 'No text/plain part found in the message');
$sPlainText = $oPlainPart->getBody();
$this->assertStringNotContainsString('<style>', $sPlainText, 'Style tag must not appear in plain text');
$this->assertStringNotContainsString('color:', $sPlainText, 'CSS color rule must not appear in plain text');
$this->assertStringNotContainsString('font-size:', $sPlainText, 'CSS font-size rule must not appear in plain text');
$this->assertStringNotContainsString('@media', $sPlainText, 'CSS @media rule must not appear in plain text');
$this->assertStringContainsString('Hello there!', $sPlainText, 'Actual content must be preserved in plain text');
}
/**
* The HTML part must contain the body content and the CSS inlined by Emogrifier.
* This guards against regressions where the wrong body (e.g. the plain-text version)
* would end up in the HTML part.
*
* @covers \Combodo\iTop\Core\Email\EmailSymfony::SetBody()
* @since N°9574
*/
public function testSetBodyHtmlPartContainsBodyAndInlinedCss(): void
{
$oEmail = new EMailSymfony();
$oEmail->SetBody('<html><body><p>Hello there!</p></body></html>', 'text/html', 'p { color: red; }');
$aParts = $this->GetAlternativePartsFromHtmlEmail($oEmail);
$oHtmlPart = null;
foreach ($aParts as $oPart) {
if ($oPart instanceof TextPart && $oPart->getMediaSubtype() === 'html') {
$oHtmlPart = $oPart;
break;
}
}
$this->assertNotNull($oHtmlPart, 'No text/html part found in the message');
$sHtmlContent = $oHtmlPart->getBody();
$this->assertStringContainsString('Hello there!', $sHtmlContent, 'HTML part must preserve the original text content');
$this->assertStringContainsString('color: red', $sHtmlContent, 'HTML part must contain the CSS inlined by Emogrifier');
}
/**
* With inline images, SetBody() wraps the AlternativePart in a RelatedPart.
* The AlternativePart must still be correctly ordered (plain first, HTML last)
* and the plain-text part must not contain CSS.
*
* @see https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.4
* @covers \Combodo\iTop\Core\Email\EmailSymfony::SetBody()
* @since N°9574
*/
public function testSetBodyWithInlineImagesHasCorrectPartStructure(): void
{
// Anonymous subclass so we can inject a fake inline image part without a real inline image in DB
$oEmail = new class () extends EMailSymfony {
protected function EmbedInlineImages(string &$sBody): array
{
return [new DataPart('fake-image-data', 'image.png', 'image/png')];
}
};
$oEmail->SetBody('<html><head><style>p { color: red; }</style></head><body><p>Hello there!</p></body></html>', 'text/html');
$oSymfonyMessage = $this->GetNonPublicProperty($oEmail, 'm_oMessage');
$oBody = $oSymfonyMessage->getBody();
// Root must be a RelatedPart when inline images are present
$this->assertInstanceOf(RelatedPart::class, $oBody, 'Root part must be multipart/related when inline images are present');
// The AlternativePart must be the first child of the RelatedPart
$aRelatedParts = $oBody->getParts();
$this->assertInstanceOf(AlternativePart::class, $aRelatedParts[0], 'First child of RelatedPart must be the AlternativePart');
// Order and CSS checks are delegated to the shared helper, which now handles RelatedPart
[$oFirstPart, $oSecondPart] = $this->GetAlternativePartsFromHtmlEmail($oEmail);
$this->assertSame('plain', $oFirstPart->getMediaSubtype(), 'First part must be text/plain (least preferred per RFC 2046)');
$this->assertSame('html', $oSecondPart->getMediaSubtype(), 'Last part must be text/html (most preferred per RFC 2046)');
}
public function provideSetBodyPlainTextDoesNotContainCss(): array
{
$sCustomStyles = 'p { color: blue; font-size: 14px; }';
return [
'<style> tag in HTML, no custom styles' => [
'<html><head><style>body { color: red; font-size: 12px; } @media print { p { color: black; } }</style></head><body><p>Hello there!</p></body></html>',
null,
],
'<style> tag in HTML with custom styles' => [
'<html><head><style>body { color: red; font-size: 12px; } @media print { p { color: black; } }</style></head><body><p>Hello there!</p></body></html>',
$sCustomStyles,
],
'custom styles only, no <style> tag' => [
'<html><body><p>Hello there!</p></body></html>',
$sCustomStyles,
],
];
}
/**
* @dataProvider provideCreateSmtpTransportSslOptions
*/
public function testCreateSmtpTransportSslOptions(string $sDsn, bool $bVerifyPeer, array $aExpectedSslOptions): void
{
$oEmail = new EMailSymfony();
/** @var EsmtpTransport $oTransport */
$oTransport = $this->InvokeNonPublicMethod(EMailSymfony::class, 'CreateSmtpTransport', $oEmail, [$sDsn, $bVerifyPeer]);
$aActualSslOptions = $oTransport->getStream()->getStreamOptions()['ssl'] ?? [];
$this->assertSame($aExpectedSslOptions, $aActualSslOptions);
}
public function provideCreateSmtpTransportSslOptions(): array
{
$aDisabledVerification = [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
];
return [
// Regression scenario (N°9584): STARTTLS starts the connection unencrypted, so the 'ssl' key
// is absent from stream options at construction time. verify_peer=false must still be applied.
'STARTTLS, verify_peer=false' => [
'smtp://localhost:587?encryption=starttls',
false,
$aDisabledVerification,
],
'implicit TLS (smtps), verify_peer=false' => [
'smtps://localhost:465',
false,
$aDisabledVerification,
],
'plain SMTP, verify_peer=false' => [
'smtp://localhost:25',
false,
$aDisabledVerification,
],
// Default behavior: verify_peer=true must leave stream options untouched (empty).
'STARTTLS, verify_peer=true (default)' => [
'smtp://localhost:587?encryption=starttls',
true,
[],
],
'implicit TLS (smtps), verify_peer=true (default)' => [
'smtps://localhost:465',
true,
[],
],
'plain SMTP, verify_peer=true (default)' => [
'smtp://localhost:25',
true,
[],
],
];
}
}