Jelajahi Sumber

Merge branch 'develop' of
git@gitlab.2iopenservice.com:opentalent/ap2i.git into develop

Conflicts:
composer.lock
symfony.lock

Olivier Massot 3 tahun lalu
induk
melakukan
d6fffaa4d2
58 mengubah file dengan 4005 tambahan dan 298 penghapusan
  1. 16 0
      .env
  2. 5 0
      composer.json
  3. 1 0
      config/bundles.php
  4. 1 1
      config/packages/docker/mailer.yaml
  5. 2 2
      config/packages/messenger.yaml
  6. 2 0
      config/packages/twig.yaml
  7. 5 4
      config/services.yaml
  8. 1 1
      old/Entity/Billing/BillPayment.php
  9. 26 0
      src/Controller/AuditController.php
  10. 2 6
      src/DataProvider/Access/AccessProfileDataProvider.php
  11. 1 5
      src/DataProvider/Cotisation/CotisationDataProvider.php
  12. 85 0
      src/Entity/Message/AbstractMessage.php
  13. 59 1
      src/Entity/Message/AbstractReport.php
  14. 24 7
      src/Entity/Message/Email.php
  15. 2 0
      src/Entity/Message/Mail.php
  16. 18 1
      src/Entity/Message/ReportEmail.php
  17. 9 1
      src/Entity/Message/ReportSms.php
  18. 2 0
      src/Entity/Message/Sms.php
  19. 20 0
      src/Enum/Core/EmailSendingTypeEnum.php
  20. 21 0
      src/Enum/Message/MessageStatusEnum.php
  21. 37 0
      src/Enum/Message/ReportMessageStatusEnum.php
  22. 17 0
      src/Enum/Message/SendStatusEnum.php
  23. 14 0
      src/Enum/Utils/EnvironnementVarEnum.php
  24. 28 0
      src/Message/Command/MailerCommand.php
  25. 39 0
      src/Message/Handler/MailerHandler.php
  26. 1 0
      src/Service/Core/ContactPointUtils.php
  27. 2 2
      src/Service/Elasticsearch/EducationNotationUpdater.php
  28. 0 68
      src/Service/MailHub.php
  29. 133 0
      src/Service/Mailer/Builder/AbstractBuilder.php
  30. 17 0
      src/Service/Mailer/Builder/AbstractBuilderInterface.php
  31. 12 0
      src/Service/Mailer/Builder/BuilderInterface.php
  32. 69 0
      src/Service/Mailer/Builder/OnSubdomainChangeMailBuilder.php
  33. 79 0
      src/Service/Mailer/Email.php
  34. 7 0
      src/Service/Mailer/EmailInterface.php
  35. 87 0
      src/Service/Mailer/EmailRecipient.php
  36. 319 0
      src/Service/Mailer/Mailer.php
  37. 28 0
      src/Service/Mailer/Model/AbstractMailerModel.php
  38. 11 0
      src/Service/Mailer/Model/MailerModelInterface.php
  39. 44 0
      src/Service/Mailer/Model/SubdomainChangeModel.php
  40. 8 1
      src/Service/Mobyt/MobytService.php
  41. 23 16
      src/Service/OnChange/Organization/OnSubdomainChange.php
  42. 2 1
      src/Service/Organization/OrganizationProfileCreator.php
  43. 41 0
      src/Service/ServiceIterator/Mailer/BuilderIterator.php
  44. 19 0
      src/Service/Utils/Environnement.php
  45. 13 0
      src/Service/Utils/StringsUtils.php
  46. 1694 0
      templates/assets/styles/foundation-emails.css
  47. 0 12
      templates/base.html.twig
  48. 99 0
      templates/emails/base.html.twig
  49. 38 0
      templates/emails/report.html.twig
  50. 27 13
      templates/emails/subdomain.html.twig
  51. 13 0
      templates/emails/test.html.twig
  52. 0 6
      templates/layout/noreply/footer.html.twig
  53. 0 124
      tests/Service/MailHubTest.php
  54. 179 0
      tests/Service/Mailer/Builder/AbstractBuilderTest.php
  55. 118 0
      tests/Service/Mailer/Builder/OnSubdomainChangeMailBuilderTest.php
  56. 433 0
      tests/Service/Mailer/MailerTest.php
  57. 45 26
      tests/Service/OnChange/Organization/OnSubdomainChangeTest.php
  58. 7 0
      tests/Service/Utils/StringsUtilsTest.php

+ 16 - 0
.env

@@ -83,3 +83,19 @@ MERCURE_PUBLIC_URL=https://local.mercure.opentalent.fr/.well-known/mercure
 # The secret key used to sign the JWTs
 MERCURE_PUBLISHER_JWT_KEY=NQEupdREijrfYvCmF2mnvZQFL9zLKDH9RCYter6tUWzjemPqzicffhc2fSf0yEmM
 ###< symfony/mercure-bundle ###
+
+###> AdminAssos configuration ###
+DATABASE_ADMINASSOS_URL=mysql://root:mysql660@db:3306/adminassos?serverVersion=5.7
+###< AdminAssos configuration ###
+
+###> Audit configuration ###
+DATABASE_AUDIT_URL=mysql://root:mysql660@db:3306/audit?serverVersion=5.7
+###< Audit configuration ###
+
+###> typo3 client ###
+TYPO3_BASE_URI=http://docker.sub.opentalent.fr
+###< typo3 client ###
+
+###> dolibarr client ###
+DOLIBARR_API_BASE_URI=https://dev-erp.2iopenservice.com/api/index.php/
+###< dolibarr client ###

+ 5 - 0
composer.json

@@ -26,6 +26,7 @@
         "knplabs/knp-snappy-bundle": "^1.9",
         "lcobucci/jwt": "^4.1",
         "lexik/jwt-authentication-bundle": "^2.8",
+        "lorenzo/pinky": "^1.0",
         "myclabs/php-enum": "^1.7",
         "nelmio/cors-bundle": "^2.1",
         "odolbeau/phone-number-bundle": "^3.1",
@@ -53,8 +54,12 @@
         "symfony/serializer": "5.4.*",
         "symfony/translation": "5.4.*",
         "symfony/twig-bundle": "^5.4",
+        "symfony/uid": "5.4.*",
         "symfony/validator": "5.4.*",
         "symfony/yaml": "5.4.*",
+        "twig/cssinliner-extra": "^3.4",
+        "twig/extra-bundle": "^3.4",
+        "twig/inky-extra": "^3.4",
         "vincent/foselastica": "1.2",
         "webonyx/graphql-php": "^14.3"
     },

+ 1 - 0
config/bundles.php

@@ -19,6 +19,7 @@ return [
     Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
     Symfony\Bundle\DebugBundle\DebugBundle::class => ['docker' => true],
     Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
+    Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
     DH\AuditorBundle\DHAuditorBundle::class => ['all' => true],
     Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
 ];

+ 1 - 1
config/packages/docker/mailer.yaml

@@ -3,7 +3,7 @@ framework:
     # @see https://symfony.com/doc/5.4/mailer.html#development-debugging
 
     # Disable the mailing in dev mode
-    dsn: 'null://null'
+#    dsn: 'null://null'
 
     # Or send all mails to the same address:
     #envelope:

+ 2 - 2
config/packages/messenger.yaml

@@ -11,9 +11,9 @@ framework:
 
         routing:
             # Route your messages to the transports
+            'App\Message\Command\MailerCommand': async
             'App\Message\Command\Parameters\AverageChange': async
             'App\Message\Command\Export': async
             'App\Message\Command\Typo3\Typo3UpdateCommand': async
             'App\Message\Command\Typo3\Typo3DeleteCommand': async
-            'App\Message\Command\Typo3\Typo3UndeleteCommand': async
-            'Symfony\Component\Mailer\Messenger\SendEmailMessage': async
+            'App\Message\Command\Typo3\Typo3UndeleteCommand': async

+ 2 - 0
config/packages/twig.yaml

@@ -1,3 +1,5 @@
 twig:
     paths:
         '%kernel.project_dir%/templates': templates
+        '%kernel.project_dir%/templates/assets/images': images
+        '%kernel.project_dir%/templates/assets/styles': styles

+ 5 - 4
config/services.yaml

@@ -12,6 +12,7 @@ services:
             $internalFilesUploadUri: '%env(INTERNAL_FILES_DOWNLOAD_URI)%'
             $bindfileBufferFile: '%env(BIND_FILE_BUFFER_FILE)%'
             $contextAwareDataPersister: '@api_platform.doctrine.orm.data_persister'
+            $opentalentNoReplyEmailAddress: 'noreply@opentalent.fr'
 
     # Logging: a shorter version of the default monolog line formatter
     monolog.formatter.message:
@@ -46,10 +47,6 @@ services:
 
     Gaufrette\Filesystem: '@knp_gaufrette.filesystem_map'
 
-    App\Service\MailHub:
-        bind:
-            $opentalentNoReplyEmailAddress: 'noreply@opentalent.fr'
-
     #########################################
     ##  TAG Services ##
     _instanceof:
@@ -61,6 +58,8 @@ services:
             tags: ['app.exporter']
         App\Service\Export\Encoder\EncoderInterface:
             tags: ['app.encoder']
+        App\Service\Mailer\Builder\BuilderInterface:
+            tags: [ 'app.mailer.builder' ]
 
     App\Service\ServiceIterator\CurrentAccessExtensionIterator:
         - !tagged_iterator app.extensions.access
@@ -70,6 +69,8 @@ services:
         - !tagged_iterator app.exporter
     App\Service\ServiceIterator\EncoderIterator:
         - !tagged_iterator app.encoder
+    App\Service\ServiceIterator\Mailer\BuilderIterator:
+        - !tagged_iterator app.mailer.builder
 
     App\Service\Dolibarr\DolibarrSyncService:
         tags:

+ 1 - 1
old/Entity/Billing/BillPayment.php

@@ -1,6 +1,6 @@
 <?php
 
-namespace AppBundle\Entity\Billing;
+namespace App\Entity\Billing;
 
 use AppBundle\Entity\AccessAndFunction\Access;
 use AppBundle\Entity\Core\Tagg;

+ 26 - 0
src/Controller/AuditController.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace App\Controller;
+
+use App\Entity\Organization\Parameters;
+use DH\Auditor\Provider\Doctrine\DoctrineProvider;
+use DH\Auditor\Provider\Doctrine\Persistence\Reader\Filter\SimpleFilter;
+use DH\Auditor\Provider\Doctrine\Persistence\Reader\Reader;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Annotation\Route;
+
+class AuditController extends AbstractController
+{
+    #[Route('/audit_test', name: 'audit_test')]
+    public function index(DoctrineProvider $doctrineProvider): Response
+    {
+        $reader = new Reader($doctrineProvider);
+        $query = $reader
+            ->createQuery(Parameters::class)
+            ->addFilter(new SimpleFilter('object_id', 5755));
+
+        dd($query->execute());
+        return new Response('ok');
+    }
+}

+ 2 - 6
src/DataProvider/Access/AccessProfileDataProvider.php

@@ -10,7 +10,6 @@ use App\ApiResources\Profile\AccessProfile;
 use App\Entity\Access\Access;
 use App\Service\Access\AccessProfileCreator;
 use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
-use Symfony\Component\Security\Core\Exception\AuthenticationException;
 use Symfony\Component\Security\Core\Security;
 
 /**
@@ -37,14 +36,11 @@ final class AccessProfileDataProvider implements ItemDataProviderInterface, Rest
         $originalAccess = null;
 
         $token = $this->security->getToken();
+
         if($token instanceof SwitchUserToken){
             $originalAccess = $token->getOriginalToken()->getUser();
         }
 
-        try {
-            return $this->accessProfileCreator->getAccessProfile($access, $originalAccess);
-        } catch (\Exception $e) {
-            return null;
-        }
+        return $this->accessProfileCreator->getAccessProfile($access, $originalAccess);
     }
 }

+ 1 - 5
src/DataProvider/Cotisation/CotisationDataProvider.php

@@ -25,10 +25,6 @@ final class CotisationDataProvider implements ItemDataProviderInterface, Restric
 
     public function getItem(string $resourceClass, $id, string $operationName = null, array $context = []): ?Cotisation
     {
-        try {
-            return $this->cotisationCreator->getCotisation($id);
-        } catch (\Exception $e) {
-            return null;
-        }
+        return $this->cotisationCreator->getCotisation($id);
     }
 }

+ 85 - 0
src/Entity/Message/AbstractMessage.php

@@ -3,13 +3,18 @@ declare(strict_types=1);
 
 namespace App\Entity\Message;
 
+use App\Annotation\OrganizationDefaultValue;
 use App\Entity\Organization\Organization;
+use App\Enum\Message\MessageStatusEnum;
 use Doctrine\ORM\Mapping as ORM;
+use Ramsey\Uuid\UuidInterface;
+use Symfony\Component\Validator\Constraints as Assert;
 
 /**
  * Classe ... qui ...
  */
 #[ORM\MappedSuperclass]
+#[OrganizationDefaultValue(fieldName: "organization")]
 abstract class AbstractMessage
 {
     #[ORM\Id]
@@ -17,12 +22,92 @@ abstract class AbstractMessage
     #[ORM\GeneratedValue]
     protected ?int $id = null;
 
+    #[ORM\Column(type: 'string', unique: true)]
+    protected $uuid = null;
+
     #[ORM\ManyToOne]
     #[ORM\JoinColumn(nullable: true)]
     protected Organization $organization;
 
+    #[ORM\Column(type: 'string', options: ['default' => 'DRAFT'])]
+    #[Assert\Choice(callback: [MessageStatusEnum::class, 'toArray'], message: 'invalid-departure-cause')]
+    protected string $status;
+
+    // @todo remplacer par le nom sendingDate
+    #[ORM\Column(type: 'datetime', nullable: true)]
+    protected ?\DateTimeInterface $dateSent = null;
+
+    #[ORM\Column(type: 'string', length: 255, nullable: true)]
+    protected string $about;
+
+    #[ORM\Column(type: 'text', nullable: true)]
+    protected string $text;
+
     public function getId(): ?int
     {
         return $this->id;
     }
+
+    public function getUuid(): ?UuidInterface
+    {
+        return $this->uuid;
+    }
+
+    public function setOrganization(Organization $organization): self
+    {
+        $this->organization = $organization;
+        return $this;
+    }
+
+    public function getOrganization(): Organization
+    {
+        return $this->organization;
+    }
+
+    public function setAbout(string $about): self
+    {
+        $this->about = $about;
+        return $this;
+    }
+
+    public function getAbout(): string
+    {
+        return $this->about;
+    }
+
+    public function setText(string $text): self
+    {
+        $this->text = $text;
+        return $this;
+    }
+
+    public function getText(): string
+    {
+        return html_entity_decode($this->text);
+    }
+
+    public function setStatus(string $status)
+    {
+        $this->status = $status;
+
+        return $this;
+    }
+
+    public function getStatus(): string
+    {
+        return $this->status;
+    }
+
+
+    public function setDateSent(\DateTimeInterface $dateSent)
+    {
+        $this->dateSent = $dateSent;
+
+        return $this;
+    }
+
+    public function getDateSent(): ?\DateTimeInterface
+    {
+        return $this->dateSent;
+    }
 }

+ 59 - 1
src/Entity/Message/AbstractReport.php

@@ -3,14 +3,72 @@ declare(strict_types=1);
 
 namespace App\Entity\Message;
 
+use App\Entity\Access\Access;
+use App\Entity\Organization\Organization;
+use App\Enum\Message\ReportMessageStatusEnum;
+use Symfony\Component\Validator\Constraints as Assert;
 use Doctrine\ORM\Mapping as ORM;
 
-
 /**
  * Classe ... qui ...
  */
 #[ORM\MappedSuperclass]
 class AbstractReport
 {
+    #[ORM\Column(type: 'date', nullable: true)]
+    protected ?\DatetimeInterface $dateSend;
+
+    #[ORM\Column(length: 255)]
+    #[Assert\Choice(callback: [ReportMessageStatusEnum::class, 'toArray'], message: 'invalid-report-type')]
+    protected string $status;
+
+    #[ORM\ManyToOne(inversedBy: 'report')]
+    protected ?Access $access;
+
+    #[ORM\ManyToOne(inversedBy: 'report')]
+    protected ?Organization $organization;
+
+    public function getDateSend(): ?\DatetimeInterface
+    {
+        return $this->dateSend;
+    }
+
+    public function setDateSend(?\DatetimeInterface $dateSend): self
+    {
+        $this->dateSend = $dateSend;
+        return $this;
+    }
+
+    public function getStatus(): string
+    {
+        return $this->status;
+    }
+
+    public function setStatus(string $status): self
+    {
+        $this->status = $status;
+        return $this;
+    }
+
+    public function getAccess(): ?Access
+    {
+        return $this->access;
+    }
+
+    public function setAccess(?Access $access): self
+    {
+        $this->access = $access;
+        return $this;
+    }
+
+    public function getOrganization(): ?Organization
+    {
+        return $this->organization;
+    }
 
+    public function setOrganization(?Organization $organization): self
+    {
+        $this->organization = $organization;
+        return $this;
+    }
 }

+ 24 - 7
src/Entity/Message/Email.php

@@ -10,6 +10,7 @@ use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
+use Ramsey\Uuid\Uuid;
 
 /**
  * @todo : A la suite de la migration, il faut supprimer le nom de la table pour avoir une table Email, et supprimer l'attribut discr.
@@ -25,12 +26,15 @@ class Email extends AbstractMessage
     #[ORM\Column(length: 255, nullable: false)]
     private string $discr = 'email';
 
+    #[ORM\Column(type: 'boolean', options: ['default' => false])]
+    private bool $isSystem = false;
+
     #[ORM\ManyToOne(inversedBy: 'emails')]
     #[ORM\JoinColumn(nullable: true)]
     private Access $author;
 
     #[ORM\OneToMany(mappedBy: 'email', targetEntity: ReportEmail::class, cascade: ['persist'], orphanRemoval: true)]
-    private Collection $report;
+    private Collection $reports;
 
     #[ORM\ManyToOne(cascade: ['persist'])]
     #[ORM\JoinColumn(nullable: true)]
@@ -50,7 +54,8 @@ class Email extends AbstractMessage
 
     public function __construct()
     {
-        $this->report = new ArrayCollection();
+        $this->uuid = Uuid::uuid4();
+        $this->reports = new ArrayCollection();
         $this->files = new ArrayCollection();
         $this->tags = new ArrayCollection();
     }
@@ -67,6 +72,18 @@ class Email extends AbstractMessage
         return $this;
     }
 
+    public function setIsSystem(bool $isSystem): self
+    {
+        $this->isSystem = $isSystem;
+
+        return $this;
+    }
+
+    public function getIsSystem(): bool
+    {
+        return $this->isSystem;
+    }
+
     public function getAuthor(): ?Access
     {
         return $this->author;
@@ -82,15 +99,15 @@ class Email extends AbstractMessage
     /**
      * @return Collection<int, ReportEmail>
      */
-    public function getReport(): Collection
+    public function getReports(): Collection
     {
-        return $this->report;
+        return $this->reports;
     }
 
     public function addReport(ReportEmail $report): self
     {
-        if (!$this->report->contains($report)) {
-            $this->report[] = $report;
+        if (!$this->reports->contains($report)) {
+            $this->reports[] = $report;
             $report->setEmail($this);
         }
 
@@ -99,7 +116,7 @@ class Email extends AbstractMessage
 
     public function removeReport(ReportEmail $report): self
     {
-        if ($this->report->removeElement($report)) {
+        if ($this->reports->removeElement($report)) {
             // set the owning side to null (unless already changed)
             if ($report->getEmail() === $this) {
                 $report->setEmail(null);

+ 2 - 0
src/Entity/Message/Mail.php

@@ -10,6 +10,7 @@ use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
+use Ramsey\Uuid\Uuid;
 
 /**
  * @todo : A la suite de la migration, il faut supprimer le nom de la table pour avoir une table Mail, et supprimer l'attribut discr.
@@ -43,6 +44,7 @@ class Mail extends AbstractMessage
 
     public function __construct()
     {
+        $this->uuid = Uuid::uuid4();
         $this->files = new ArrayCollection();
         $this->tags = new ArrayCollection();
     }

+ 18 - 1
src/Entity/Message/ReportEmail.php

@@ -9,6 +9,7 @@ use Doctrine\ORM\Mapping as ORM;
 /**
  * Classe ... qui ...
  */
+#[ORM\Table(name: 'ReportMessage')]
 #[Auditable]
 #[ORM\Entity]
 class ReportEmail extends AbstractReport
@@ -18,9 +19,13 @@ class ReportEmail extends AbstractReport
     #[ORM\GeneratedValue]
     private ?int $id = null;
 
-    #[ORM\ManyToOne(inversedBy: 'report')]
+    #[ORM\ManyToOne(inversedBy: 'reports')]
+    #[ORM\JoinColumn('message_id')]
     private Email $email;
 
+    #[ORM\Column(length: 255)]
+    private string $addressEmail;
+
     public function getId(): ?int
     {
         return $this->id;
@@ -37,4 +42,16 @@ class ReportEmail extends AbstractReport
 
         return $this;
     }
+
+    public function getAddressEmail(): ?string
+    {
+        return $this->addressEmail;
+    }
+
+    public function setAddressEmail(?string $addressEmail): self
+    {
+        $this->addressEmail = $addressEmail;
+
+        return $this;
+    }
 }

+ 9 - 1
src/Entity/Message/ReportSms.php

@@ -9,6 +9,7 @@ use Doctrine\ORM\Mapping as ORM;
 /**
  * Classe ... qui ...
  */
+#[ORM\Table(name: 'ReportMessage')]
 #[Auditable]
 #[ORM\Entity]
 class ReportSms extends AbstractReport
@@ -18,9 +19,16 @@ class ReportSms extends AbstractReport
     #[ORM\GeneratedValue]
     private ?int $id = null;
 
-    #[ORM\ManyToOne(inversedBy: 'report')]
+    #[ORM\ManyToOne(inversedBy: 'reports')]
+    #[ORM\JoinColumn('message_id')]
     private Sms $sms;
 
+    #[ORM\Column(length: 255)]
+    private string $mobile;
+
+    #[ORM\Column(nullable: true)]
+    private $smsId;
+
     public function getId(): ?int
     {
         return $this->id;

+ 2 - 0
src/Entity/Message/Sms.php

@@ -9,6 +9,7 @@ use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
+use Ramsey\Uuid\Uuid;
 
 /**
  * @todo : A la suite de la migration, il faut supprimer le nom de la table pour avoir une table Sms, et supprimer l'attribut discr.
@@ -39,6 +40,7 @@ class Sms extends AbstractMessage
 
     public function __construct()
     {
+        $this->uuid = Uuid::uuid4();
         $this->report = new ArrayCollection();
         $this->tags = new ArrayCollection();
     }

+ 20 - 0
src/Enum/Core/EmailSendingTypeEnum.php

@@ -0,0 +1,20 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Enum\Core;
+
+use MyCLabs\Enum\Enum;
+
+/**
+ * Type de point de contact
+ *
+ * @method static TO()
+ * @method static BBC()
+ * @method static CC()
+ */
+class EmailSendingTypeEnum extends Enum
+{
+    private const TO = 'TO';
+    private const BBC = 'BBC';
+    private const CC = 'CC';
+}

+ 21 - 0
src/Enum/Message/MessageStatusEnum.php

@@ -0,0 +1,21 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Enum\Message;
+
+use MyCLabs\Enum\Enum;
+
+/**
+ * Cycle Enum.
+ */
+class MessageStatusEnum extends Enum
+{
+    private const DRAFT = 'DRAFT';
+    private const CREATING = 'CREATING';
+    private const READY = 'READY';
+    private const SEND = 'SEND';
+    private const PRINTED = 'PRINTED';
+    private const SENDING_IN_PROGRESS = 'SENDING_IN_PROGRESS';
+    private const FAILED = 'FAILED';
+    private const NO_RECIPIENT = 'NO_RECIPIENT';
+}

+ 37 - 0
src/Enum/Message/ReportMessageStatusEnum.php

@@ -0,0 +1,37 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Enum\Message;
+
+use MyCLabs\Enum\Enum;
+
+/**
+ * Report Mail status.
+ *
+ */
+class ReportMessageStatusEnum extends Enum
+{
+  private const DELIVERED = 'DELIVERED';
+  private const PRINTED = 'PRINTED';
+  private const DELIVERY_IN_PROGRESS = 'DELIVERY_IN_PROGRESS';
+  private const NOT_DELIVERED = 'NOT_DELIVERED';
+  private const INVALID = 'INVALID';
+  private const MISSING = 'MISSING';
+  private const WAITING = "WAITING";
+  private const WAIT4DLVR = "WAITING_DELIVERY";
+  private const SENT = "SENT";
+  private const DLVRD = "DLVRD";
+  private const TOOM4USER = "TOO_MANY_SMS_FROM_USER";
+  private const TOOM4NUM = "TOO_MANY_SMS_FOR_NUMBER";
+  private const ERROR = "ERROR";
+  private const TIMEOUT = "TIMEOUT";
+  private const UNKNRCPT = "UNPARSABLE_RCPT";
+  private const UNKNPFX = "UNKNOWN_PREFIX";
+  private const DEMO = "SENT_IN_DEMO_MODE";
+  private const SCHEDULED = "WAITING_DELAYED";
+  private const INVALIDDST = "INVALID_DESTINATION";
+  private const BLACKLISTED = "NUMBER_BLACKLISTED";
+  private const NUMBER_USER_BLACKLISTED = "BLACKLISTED";
+  private const KO = "SMSC_REJECTED";
+  private const INVALIDCONTENTS = "INVALID_CONTENTS";
+}

+ 17 - 0
src/Enum/Message/SendStatusEnum.php

@@ -0,0 +1,17 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Enum\Message;
+
+use MyCLabs\Enum\Enum;
+
+/**
+ * Cycle Enum.
+ * @method static READY()
+ * @method static FAILED()
+ */
+class SendStatusEnum extends Enum
+{
+    private const FAILED = 'FAILED';
+    private const READY = 'READY';
+}

+ 14 - 0
src/Enum/Utils/EnvironnementVarEnum.php

@@ -0,0 +1,14 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Enum\Utils;
+
+use MyCLabs\Enum\Enum;
+
+/**
+ * @method static APP_ENV()
+ */
+class EnvironnementVarEnum extends Enum
+{
+    private const APP_ENV = 'APP_ENV';
+}

+ 28 - 0
src/Message/Command/MailerCommand.php

@@ -0,0 +1,28 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Message\Command;
+
+use App\Service\Mailer\Model\MailerModelInterface;
+
+/**
+ * Classe ... qui ...
+ */
+class MailerCommand
+{
+    public function __construct(
+        private MailerModelInterface $model
+    )
+    {
+    }
+
+    public function getMailerModel(): MailerModelInterface
+    {
+        return $this->model;
+    }
+
+    public function setMailerModel(MailerModelInterface $model): void
+    {
+        $this->model = $model;
+    }
+}

+ 39 - 0
src/Message/Handler/MailerHandler.php

@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Message\Handler;
+
+use App\Entity\Message\Email;
+use App\Message\Command\MailerCommand;
+use App\Repository\Access\AccessRepository;
+use App\Service\Mailer\Mailer;
+use App\Service\Notifier;
+use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
+
+class MailerHandler implements MessageHandlerInterface
+{
+    public function __construct(
+        private Mailer           $mailer,
+        private Notifier         $notifier,
+        private AccessRepository $accessRepository
+    )
+    {
+    }
+
+    public function __invoke(MailerCommand $mailerCommand)
+    {
+        $mailerModel = $mailerCommand->getMailerModel();
+        $emails = $this->mailer->main($mailerModel);
+
+        if ($mailerModel->getNotify()) {
+            /** @var Email */
+            $email = $emails->first()->getEmailEntity();
+
+            $this->notifier->notifyMessage(
+                $this->accessRepository->find($mailerCommand->getMailerModel()->getSenderId()),
+                ['about' => $email->getAbout()]
+            );
+        }
+    }
+}

+ 1 - 0
src/Service/Core/ContactPointUtils.php

@@ -5,6 +5,7 @@ namespace App\Service\Core;
 
 use App\Entity\Access\Access;
 use App\Entity\Core\ContactPoint;
+use App\Entity\Organization\Organization;
 use App\Enum\Core\ContactPointTypeEnum;
 use App\Repository\Core\ContactPointRepository;
 use App\Test\Service\Access\ContactPointUtilsTest;

+ 2 - 2
src/Service/Elasticsearch/EducationNotationUpdater.php

@@ -10,7 +10,7 @@ use FOS\ElasticaBundle\Persister\ObjectPersister;
  */
 class EducationNotationUpdater
 {
-    public function __construct(private ObjectPersister $objectPersisterOrganization)
+    public function __construct(private ObjectPersister $objectPersisterEducationNotation)
     {
     }
 
@@ -19,6 +19,6 @@ class EducationNotationUpdater
      * @param $educationNotations
      */
     public function update(array $educationNotations){
-        $this->objectPersisterOrganization->replaceMany($educationNotations);
+        $this->objectPersisterEducationNotation->replaceMany($educationNotations);
     }
 }

+ 0 - 68
src/Service/MailHub.php

@@ -1,68 +0,0 @@
-<?php
-
-namespace App\Service;
-
-use App\Entity\Access\Access;
-use App\Entity\Organization\Organization;
-use App\Service\Access\Utils as AccessUtils;
-use App\Service\Core\ContactPointUtils;
-use Symfony\Bridge\Twig\Mime\TemplatedEmail;
-use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
-use Symfony\Component\Mailer\MailerInterface;
-use Symfony\Component\Mime\Address;
-
-class MailHub
-{
-    public function __construct(
-        private MailerInterface $mailer,
-        private string $opentalentNoReplyEmailAddress,
-        private ContactPointUtils $contactPointUtils,
-        private AccessUtils $accessUtils
-    ) {}
-
-    /**
-     * Sends an automatic 'do-not-reply'-type email to the user
-     *
-     * NB: These emails are not registered in the DB
-     *
-     * @param Access $access
-     * @param string $subject
-     * @param string $template
-     * @param array $data
-     * @throws TransportExceptionInterface
-     */
-    public function sendAutomaticEmailTo(Access $access, string $subject, string $template, array $data): void
-    {
-        $contactPoint = $this->contactPointUtils->getPersonContactPointPrincipal($access);
-        if ($contactPoint === null || empty($contactPoint->getEmail()) || $contactPoint->getEmailInvalid()) {
-            throw new \RuntimeException('Access has no principal email address, abort');
-        }
-        /** @noinspection NullPointerExceptionInspection */
-        $to = new Address($contactPoint->getEmail(), $access->getPerson()->getFullName() ?? $access->getPerson()->getUsername());
-
-        $context = ['_to' => $to];
-        $context = array_merge($context, $data);
-
-        $email = (new TemplatedEmail())
-            ->from($this->opentalentNoReplyEmailAddress)
-            ->to($to)
-            ->subject($subject)
-            ->htmlTemplate('@templates/emails/' . $template . '.html.twig')
-            ->context($context);
-
-        $this->mailer->send($email);
-    }
-
-    /**
-     * Sends an automatic 'do-not-reply'-type email to the admin of the organization
-     * @throws TransportExceptionInterface
-     */
-    public function sendAutomaticEmailToAdmin(Organization $organization, string $subject, string $template, array $data): void
-    {
-        $admin = $this->accessUtils->findAdminFor($organization);
-        if ($admin === null) {
-            throw new \RuntimeException('No admin found for organization ' . $organization->getId());
-        }
-        $this->sendAutomaticEmailTo($admin, $subject, $template, $data);
-    }
-}

+ 133 - 0
src/Service/Mailer/Builder/AbstractBuilder.php

@@ -0,0 +1,133 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Mailer\Builder;
+
+use App\Entity\Access\Access;
+use App\Entity\Core\ContactPoint;
+use App\Entity\Organization\Organization;
+use App\Enum\Message\ReportMessageStatusEnum;
+use App\Repository\Core\ContactPointRepository;
+use App\Service\Mailer\Email;
+use App\Entity\Message\Email as EmailEntity;
+use App\Service\Mailer\EmailRecipient;
+use App\Tests\Service\Mailer\Builder\AbstractBuilderTest;
+use Symfony\Contracts\Service\Attribute\Required;
+use Twig\Environment;
+
+/**
+ * Classe AbstractBuilder qui définit les fonctions de bases de chaque builder
+ */
+class AbstractBuilder implements AbstractBuilderInterface
+{
+    protected Environment $twig;
+    protected ContactPointRepository $contactPointRepository;
+
+    #[Required]
+    public function setTwig(Environment $twig): void
+    { $this->twig = $twig; }
+
+    #[Required]
+    public function setContactPointRepository(ContactPointRepository $contactPointRepository): void
+    { $this->contactPointRepository = $contactPointRepository; }
+
+
+    /**
+     * @param string $subject
+     * @param Access $author
+     * @param string $content
+     * @return EmailEntity
+     * @see AbstractBuilderTest::testBuildEmailEntity()
+     */
+    public function buildEmailEntity(string $subject, Access $author, string $content): EmailEntity{
+        return (new EmailEntity())
+            ->setAuthor($author)
+            ->setAbout($subject)
+            ->setIsSystem(true)
+            ->setDateSent(new \DateTime('now'))
+            ->setText($content)
+            ;
+    }
+
+    /**
+     * @param string $template
+     * @param array $context
+     * @return string
+     * @throws \Twig\Error\LoaderError
+     * @throws \Twig\Error\RuntimeError
+     * @throws \Twig\Error\SyntaxError
+     * @throws \Twig_Error_Loader
+     * @throws \Twig_Error_Runtime
+     * @throws \Twig_Error_Syntax
+     *
+     * @see AbstractBuilderTest::testRender()
+     */
+    public function render(string $template, array $context)
+    {
+        return $this->twig->render(sprintf('@templates/emails/%s.html.twig', $template), $context);
+    }
+
+    /**
+     * @param Email $email
+     * @param Access|Organization|string $target
+     * @param string $sendType
+     * @param string|null $contactPointType
+     *
+     * @see AbstractBuilderTest::testAddRecipient()
+     */
+    public function addRecipient(Email $email, Access|Organization|string $target, string $sendType, string $contactPointType = null): void{
+        $emailRecipient = (new EmailRecipient())
+            ->setSendType($sendType);
+
+        if($target instanceof Access){
+            $emailRecipient->setAccess($target);
+            $emailRecipient->setName($target->getPerson()->getFullName() ?? $target->getOrganization()->getName());
+            $this->setMailToRecipient($this->contactPointRepository->getByTypeAndPerson($contactPointType, $target->getPerson()), $emailRecipient);
+        }else if ($target instanceof Organization){
+            $emailRecipient->setOrganization($target);
+            $emailRecipient->setName($target->getName());
+            $this->setMailToRecipient($this->contactPointRepository->getByTypeAndOrganization($contactPointType, $target), $emailRecipient);
+        }else{
+            $emailRecipient->setSendStatus(ReportMessageStatusEnum::DELIVERED()->getValue());
+            $emailRecipient->setEmailAddress($target);
+        }
+
+        $email->addEmailRecipient($emailRecipient);
+    }
+
+    /**
+     * @param array $contactPoints
+     * @param EmailRecipient $recipient
+     *
+     * @see AbstractBuilderTest::testSetMailToRecipient()
+     */
+    public function setMailToRecipient(array $contactPoints, EmailRecipient $recipient): void {
+        if($contactPoint = $this->getFirstContactPointWithEmail($contactPoints)){
+            $recipient->setSendStatus(ReportMessageStatusEnum::DELIVERED()->getValue());
+            $recipient->setEmailAddress($contactPoint->getEmail());
+        }else{
+            $recipient->setSendStatus(ReportMessageStatusEnum::MISSING()->getValue());
+        }
+    }
+
+    /**
+     * @param $contactPoints
+     * @return ContactPoint
+     *
+     * @see AbstractBuilderTest::testGetFirstContactPointWithEmail()
+     */
+    public function getFirstContactPointWithEmail($contactPoints): ?ContactPoint{
+        $find = false;
+        $cmpt = 0;
+        $contactPoint = null;
+
+        while(count($contactPoints) > $cmpt && !$find){
+            if($contactPoints[$cmpt]->getEmail()){
+                $find = true;
+                $contactPoint = $contactPoints[$cmpt];
+            }
+            $cmpt++;
+        }
+        return $contactPoint;
+    }
+}

+ 17 - 0
src/Service/Mailer/Builder/AbstractBuilderInterface.php

@@ -0,0 +1,17 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Mailer\Builder;
+
+use App\Entity\Access\Access;
+use App\Entity\Organization\Organization;
+use App\Service\Mailer\Email;
+
+interface AbstractBuilderInterface {
+
+    public function render(string $template, array $context);
+
+    public function buildEmailEntity(string $subject, Access $author, string $content);
+
+    public function addRecipient(Email $email, Access|Organization|string $target, string $sendType, string $contactPointType = null);
+}

+ 12 - 0
src/Service/Mailer/Builder/BuilderInterface.php

@@ -0,0 +1,12 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Mailer\Builder;
+
+use App\Service\Mailer\Model\MailerModelInterface;
+
+interface BuilderInterface extends AbstractBuilderInterface {
+    public function support(MailerModelInterface $mailerModel): bool;
+
+    public function build(MailerModelInterface $mailerModel);
+}

+ 69 - 0
src/Service/Mailer/Builder/OnSubdomainChangeMailBuilder.php

@@ -0,0 +1,69 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Mailer\Builder;
+
+use App\Entity\Access\Access;
+use App\Entity\Organization\Organization;
+use App\Entity\Organization\Subdomain;
+use App\Enum\Core\ContactPointTypeEnum;
+use App\Enum\Core\EmailSendingTypeEnum;
+use App\Service\Access\Utils as AccessUtils;
+use App\Service\Mailer\Email;
+use App\Service\Mailer\Model\MailerModelInterface;
+use App\Service\Mailer\Model\SubdomainChangeModel;
+use App\Tests\Service\Mailer\Builder\OnSubdomainChangeMailBuilderTest;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\ORM\EntityManagerInterface;
+use function Symfony\Component\DependencyInjection\Loader\Configurator\param;
+
+/**
+ * Classe SubDomainChangeBuilder qui est chargé de construire l'Email qui sera envoyé
+ */
+class OnSubdomainChangeMailBuilder extends AbstractBuilder implements BuilderInterface
+{
+    public function __construct(
+        private EntityManagerInterface $entityManager,
+        private string $opentalentNoReplyEmailAddress,
+        private AccessUtils $accessUtils
+    )
+    {
+    }
+
+    public function support(MailerModelInterface $mailerModel): bool
+    {
+        return $mailerModel instanceof SubdomainChangeModel;
+    }
+
+    /**
+     * @param SubdomainChangeModel $mailerModel
+     * @return ArrayCollection
+     *
+     * @see OnSubdomainChangeMailBuilderTest::testBuild()
+     */
+    public function build(MailerModelInterface $mailerModel): ArrayCollection
+    {
+        $subdomain = $this->entityManager->getRepository(Subdomain::class)->find($mailerModel->getSubdomainId());
+        $organization = $this->entityManager->getRepository(Organization::class)->find($mailerModel->getOrganizationId());
+        $author = $this->entityManager->getRepository(Access::class)->find($mailerModel->getSenderId());
+        $admin = $this->accessUtils->findAdminFor($organization);
+
+        $context = [
+            'access' => $admin,
+            'organization' => $organization,
+            'subdomain' => $subdomain,
+            'url' => $mailerModel->getUrl()
+        ];
+        $content = $this->render('subdomain', $context);
+
+        $email= (new Email())
+            ->setEmailEntity($this->buildEmailEntity( 'Nouveau sous domaine: ' . $subdomain->getSubdomain(), $author, $content))
+            ->setContent($content)
+            ->setFrom($this->opentalentNoReplyEmailAddress)
+            ->setFromName($organization->getName())
+        ;
+        $this->addRecipient($email, $admin, EmailSendingTypeEnum::TO()->getValue(), ContactPointTypeEnum::PRINCIPAL()->getValue());
+
+        return new ArrayCollection([$email]);
+    }
+}

+ 79 - 0
src/Service/Mailer/Email.php

@@ -0,0 +1,79 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Mailer;
+
+use App\Entity\Message\Email as EmailEntity;
+use Doctrine\Common\Collections\ArrayCollection;
+
+/**
+ * Classe Email qui contient les informations nécessaire pour assurer l'envoie d'un mail
+ */
+class Email implements EmailInterface
+{
+    private string $from;
+    private string $fromName;
+    private EmailEntity $emailEntity;
+    private string $content;
+    private ArrayCollection $emailRecipients;
+
+    public function __construct()
+    {
+        $this->emailRecipients = new ArrayCollection();
+    }
+
+    public function getFrom(): string
+    {
+        return $this->from;
+    }
+
+    public function setFrom(string $from): self
+    {
+        $this->from = $from;
+        return $this;
+    }
+
+    public function geFromName(): string
+    {
+        return $this->fromName;
+    }
+
+    public function setFromName(string $fromName): self
+    {
+        $this->fromName = $fromName;
+        return $this;
+    }
+
+    public function getEmailEntity(): EmailEntity
+    {
+        return $this->emailEntity;
+    }
+
+    public function setEmailEntity(EmailEntity $emailEntity): self
+    {
+        $this->emailEntity = $emailEntity;
+        return $this;
+    }
+
+    public function getContent(): string
+    {
+        return $this->content;
+    }
+
+    public function setContent(string $content): self
+    {
+        $this->content = $content;
+        return $this;
+    }
+
+    public function addEmailRecipient(EmailRecipient $emailRecipients): self{
+        $this->emailRecipients->add($emailRecipients);
+        return $this;
+    }
+
+    public function getEmailRecipients(): ArrayCollection
+    {
+        return $this->emailRecipients;
+    }
+
+}

+ 7 - 0
src/Service/Mailer/EmailInterface.php

@@ -0,0 +1,7 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Mailer;
+
+interface EmailInterface{
+}

+ 87 - 0
src/Service/Mailer/EmailRecipient.php

@@ -0,0 +1,87 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Mailer;
+
+use App\Entity\Access\Access;
+use App\Entity\Organization\Organization;
+
+/**
+ * Classe AbstractRecipient qui contient les informations des destinataires
+ */
+class EmailRecipient
+{
+    private string $sendStatus;
+    private string $name = '';
+    private string $emailAddress;
+    private string $sendType;
+    private ?Access $access = null;
+    private ?Organization $organization = null;
+
+
+    public function getSendType(): string
+    {
+        return $this->sendType;
+    }
+
+    public function setSendType(string $sendType): self
+    {
+        $this->sendType = $sendType;
+        return $this;
+    }
+
+    public function getSendStatus(): string
+    {
+        return $this->sendStatus;
+    }
+
+    public function setSendStatus(string $sendStatus): self
+    {
+        $this->sendStatus = $sendStatus;
+        return $this;
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    public function setName(string $name): self
+    {
+        $this->name = $name;
+        return $this;
+    }
+
+    public function getEmailAddress(): string
+    {
+        return $this->emailAddress;
+    }
+
+    public function setEmailAddress(string $emailAddress): self
+    {
+        $this->emailAddress = $emailAddress;
+        return $this;
+    }
+
+    public function getAccess(): ?Access
+    {
+        return $this->access;
+    }
+
+    public function setAccess(?Access $access): self
+    {
+        $this->access = $access;
+        return $this;
+    }
+
+    public function getOrganization(): ?Organization
+    {
+        return $this->organization;
+    }
+
+    public function setOrganization(?Organization $organization): self
+    {
+        $this->organization = $organization;
+        return $this;
+    }
+}

+ 319 - 0
src/Service/Mailer/Mailer.php

@@ -0,0 +1,319 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Mailer;
+
+use App\Entity\Message\ReportEmail;
+use App\Enum\Core\EmailSendingTypeEnum;
+use App\Enum\Message\MessageStatusEnum;
+use App\Enum\Utils\EnvironnementVarEnum;
+use App\Service\Mailer\Model\MailerModelInterface;
+use App\Service\ServiceIterator\Mailer\BuilderIterator;
+use App\Service\Utils\Environnement;
+use App\Service\Utils\StringsUtils;
+use App\Enum\Message\ReportMessageStatusEnum;
+use App\Tests\Service\Mailer\MailerTest;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\ORM\EntityManagerInterface;
+use Psr\Log\LoggerInterface;
+use Symfony\Bridge\Twig\Mime\TemplatedEmail;
+use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
+use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\Mime\Address;
+use Symfony\Component\Mime\Email as SymfonyEmail;
+
+/**
+ * Classe Mailer : Service assurant l'envoie d'un mail à un destinataire
+ */
+class Mailer
+{
+    public function __construct(
+        private MailerInterface $symfonyMailer,
+        private string $opentalentNoReplyEmailAddress,
+        private BuilderIterator $builderIterator,
+        private StringsUtils $stringsUtils,
+        private EntityManagerInterface $entityManager,
+        private Environnement $environnement,
+        private LoggerInterface $logger
+    )
+    {
+    }
+
+    /**
+     * Main fonction qui itère les différentes étapes nécessaires à l'envoie d'un email
+     *  - Le Build
+     *  - Le Send
+     *  - Le Reporting
+     *
+     * @param MailerModelInterface $mailerModel
+     * @throws \Exception
+     *
+     * @see MailerTest::testMain()
+     */
+    public function main(MailerModelInterface $mailerModel): ArrayCollection{
+        $builderService = $this->builderIterator->getBuilderFor($mailerModel);
+        $emailsCollection = $builderService->build($mailerModel);
+
+        $emailsCollection = $this->reduceEmailsCollectionInPreproduction($emailsCollection);
+
+        /** @var Email $email */
+        foreach ($emailsCollection as $email){
+            //si l'email n'a pas de destinataire, on ne l'envoi pas...
+            if($email->getEmailRecipients()->isEmpty()){
+                $email->getEmailEntity()->setStatus(MessageStatusEnum::NO_RECIPIENT()->getValue());
+                continue;
+            }
+
+            //Envoi du mail
+            $this->send($email);
+
+            //Persistance de l'entité Email
+            $this->persistEmailEntity($email);
+        }
+
+        //Envoi du rapport
+        $this->sendReport($emailsCollection);
+
+        $this->entityManager->flush();
+
+        return $emailsCollection;
+    }
+
+    /**
+     * Fonction d'envoie
+     * @param EmailInterface $email
+     *
+     * @see MailerTest::testSend()
+     */
+    public function send(EmailInterface $email): void{
+        //On créer le mail
+        $symfonyMail = $this->createSymfonyEmail($email);
+
+        $this->addRecipients($symfonyMail, $email);
+
+        $this->setAntiSpam($email, $symfonyMail->getTo());
+
+        $this->setSymfonyEmailContent($symfonyMail, $email);
+
+        $this->addHeaders($symfonyMail, $email);
+
+        // @todo
+        //$this->addAttachments();
+
+        //On tente d'envoyer
+        try {
+            $this->symfonyMailer->send($symfonyMail);
+            $email->getEmailEntity()->setStatus(MessageStatusEnum::SEND()->getValue());
+            $email->getEmailEntity()->setDateSent(new \DateTime('now'));
+        } catch (\Exception $e) {
+            $this->logger->error('Error while sending email');
+            $this->logger->error($e);
+
+            $email->getEmailEntity()->setStatus(MessageStatusEnum::FAILED()->getValue());
+        }
+    }
+
+    /**
+     * Envoie le rapport d'envoi
+     * @param ArrayCollection $emails
+     *
+     * @see MailerTest::testSendReport()
+     */
+    public function sendReport(ArrayCollection $emails): void{
+        $reportEmail = $this->createReportEmail($emails);
+        try {
+            $this->symfonyMailer->send($reportEmail);
+        } catch (TransportExceptionInterface $e) {
+            $this->logger->error('Error while sending report');
+            $this->logger->error($e);
+        }
+    }
+
+    /**
+     * Création du Mail qui sera envoyé via le Mailer de Symfony
+     * @param Email $email
+     * @return SymfonyEmail
+     *
+     * @see MailerTest::testCreateSymfonyEmail()
+     */
+    public function createSymfonyEmail(Email $email): SymfonyEmail{
+        $addressMailFrom = new Address($email->getFrom(), $email->geFromName());
+
+        return (new SymfonyEmail())
+            ->from($addressMailFrom)
+            ->replyTo($addressMailFrom)
+            ->returnPath(Address::create("mail.report@opentalent.fr"))
+            ->subject($email->getEmailEntity()->getAbout())
+            ;
+    }
+
+    /**
+     * @param SymfonyEmail $symfonyEmail
+     * @param Email $email
+     *
+     * @see MailerTest::testSetSymfonyEmailContent()
+     */
+    public function setSymfonyEmailContent(SymfonyEmail $symfonyEmail, Email $email){
+        $symfonyEmail
+            ->html($email->getContent())
+            ->text($this->stringsUtils->convertHtmlToText($email->getContent()));
+    }
+
+    /**
+     * Créer le Templated Email contenant le rapport d'envoi
+     * @param ArrayCollection $emails
+     * @return object|TemplatedEmail
+     *
+     * @see MailerTest::testCreateReportEmail()
+     */
+    public function createReportEmail(ArrayCollection $emails){
+        [$delivered, $unDelivered] = $this->getDeliveredAndUndelivered($emails);
+
+        /** @var Email $email */
+        $email = $emails->first();
+        return (new TemplatedEmail())
+            ->from($this->opentalentNoReplyEmailAddress)
+            ->subject(sprintf('Rapport d\'envoi du message : %s', $email->getEmailEntity()->getAbout()))
+            ->htmlTemplate('@templates/emails/report.html.twig')
+            ->context(
+                [
+                    'email_example' => $email->getEmailEntity(),
+                    'delivered' => $delivered,
+                    'unDelivered' => $unDelivered
+                ]
+            )
+            ->addTo(new Address($email->getFrom(), $email->geFromName()))
+        ;
+    }
+
+    /**
+     * Récupère les recipient delivered & undelivered de l'ensemble des emails
+     * @param ArrayCollection $emails
+     * @return array[]
+     *
+     * @see MailerTest::testGetDeliveredAndUndelivered()
+     */
+    function getDeliveredAndUndelivered(ArrayCollection $emails): array {
+        $delivered = [];
+        $unDelivered = [];
+
+        foreach ($emails as $email){
+            $emailRecipients = $email->getEmailRecipients();
+            /** @var EmailRecipient $emailRecipient */
+            foreach ($emailRecipients as $emailRecipient){
+                if($emailRecipient->getSendStatus() === ReportMessageStatusEnum::MISSING()->getValue()){
+                    $unDelivered[] = $emailRecipient;
+                }else if($emailRecipient->getSendStatus() === ReportMessageStatusEnum::DELIVERED()->getValue()){
+                    $delivered[] = $emailRecipient;
+                }
+            }
+        }
+        return [$delivered, $unDelivered];
+    }
+
+    /**
+     * Persist l'Email
+     * @param Email $email
+     *
+     * @see MailerTest::testPersistEmailEntity()
+     */
+    public function persistEmailEntity(Email $email){
+        $emailEntity = $email->getEmailEntity();
+        /** @var EmailRecipient $emailRecipient */
+        foreach ($email->getEmailRecipients() as $emailRecipient){
+            $emailEntity->addReport($this->createReport($emailRecipient));
+        }
+        $this->entityManager->persist($emailEntity);
+    }
+
+    /**
+     * Création du rapport
+     * @param EmailRecipient $emailRecipient
+     *
+     * @see MailerTest::testCreateReport()
+     */
+    public function createReport(EmailRecipient $emailRecipient): ReportEmail{
+        return (new ReportEmail())
+            ->setAddressEmail($emailRecipient->getEmailAddress())
+            ->setAccess($emailRecipient->getAccess())
+            ->setOrganization($emailRecipient->getOrganization())
+            ->setDateSend(new \DateTime('now'))
+            ->setStatus($emailRecipient->getSendStatus())
+        ;
+    }
+
+    /**
+     * Reduit le nombre d'emails a envoyer si on ne se trouve pas en prod
+     * @param ArrayCollection $emailsCollection
+     *
+     * @see MailerTest::testReduceEmailsCollectionInPreproduction()
+     */
+    public function reduceEmailsCollectionInPreproduction(ArrayCollection $emailsCollection): ArrayCollection {
+        if($this->environnement->get(EnvironnementVarEnum::APP_ENV()->getValue()) !== 'prod' && $emailsCollection->count() > 20) {
+            $startEmails = $emailsCollection->slice(0, 10);
+            $endEmails = $emailsCollection->slice($emailsCollection->count() - 11, 10);
+            return new ArrayCollection([...$startEmails, ...$endEmails]);
+        }
+        return $emailsCollection;
+    }
+
+    /**
+     * @param SymfonyEmail $symfonyMail
+     * @param Email $email
+     *
+     * @see MailerTest::testAddHeaders()
+     */
+    public function addHeaders(SymfonyEmail $symfonyMail, Email $email){
+        $symfonyMail->getHeaders()->addTextHeader('List-Unsubscribe','mailto:'.$email->getFrom().'?subject=désabonnement');
+        $symfonyMail->getHeaders()->addTextHeader('X-ID-OT', $email->getEmailEntity()->getUuid()->toString());
+    }
+
+    /**
+     * On change le #__#ANTISPAM_PERSON_EMAIL#__# par la liste des emails afin d'éviter le spamming sur l'envoi en masse
+     * @param Email $email
+     * @param array $to
+     *
+     * @see MailerTest::testSetAntiSpam()
+     */
+    public function setAntiSpam(Email $email, array $addresses){
+        // map des Address pour ne conserver qu'un tableau d'email
+        $to = array_map(function(Address $address){
+            return $address->getAddress();
+        }, $addresses);
+
+        $email->setContent(str_replace('#__#ANTISPAM_PERSON_EMAIL#__#', implode(',', $to), $email->getContent()));
+    }
+
+    /**
+     * On ajoute les destinataires suivant le type d'envoie souhaité
+     * @param SymfonyEmail $symfonyMail
+     * @param Address $addressesMail
+     *
+     * @see MailerTest::testAddRecipients()
+     */
+    public function addRecipients(SymfonyEmail $symfonyMail, Email $email): void{
+        $allReadySend = [];
+
+        foreach ($email->getEmailRecipients() as $emailRecipient){
+
+            $addressMail = new Address($emailRecipient->getEmailAddress(), $emailRecipient->getName());
+
+            //On envoi pas en double
+            if(!in_array($addressMail, $allReadySend)){
+                $allReadySend[] = $addressMail;
+
+                switch($emailRecipient->getSendType()){
+                    case EmailSendingTypeEnum::TO()->getValue():
+                        $symfonyMail->addTo($addressMail);
+                        break;
+                    case EmailSendingTypeEnum::BBC()->getValue():
+                        $symfonyMail->addBcc($addressMail);
+                        break;
+                    case EmailSendingTypeEnum::CC()->getValue():
+                        $symfonyMail->addCc($addressMail);
+                        break;
+                }
+            }
+        }
+    }
+}

+ 28 - 0
src/Service/Mailer/Model/AbstractMailerModel.php

@@ -0,0 +1,28 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Mailer\Model;
+
+abstract  class AbstractMailerModel
+{
+    protected int $senderId;
+    protected bool $notify = true;
+
+    public function setSenderId(int $accessId) : self{
+        $this->senderId = $accessId;
+        return $this;
+    }
+
+    public function getSenderId(): int {
+        return $this->senderId;
+    }
+
+    public function setNotify(bool $notify) : self{
+        $this->notify = $notify;
+        return $this;
+    }
+
+    public function getNotify(): bool {
+        return $this->notify;
+    }
+}

+ 11 - 0
src/Service/Mailer/Model/MailerModelInterface.php

@@ -0,0 +1,11 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Mailer\Model;
+
+interface MailerModelInterface {
+    public function setSenderId(int $accessId);
+    public function getSenderId();
+    public function setNotify(bool $notify);
+    public function getNotify();
+}

+ 44 - 0
src/Service/Mailer/Model/SubdomainChangeModel.php

@@ -0,0 +1,44 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Mailer\Model;
+
+/**
+ * Classe SubdomainChangeModel qui conserve les données pour construire le mail lors du changement de nom de domaine
+ */
+class SubdomainChangeModel extends AbstractMailerModel implements MailerModelInterface
+{
+    private int $organizationId;
+    private int $subdomainId;
+    private string $url;
+
+    public function __construct()
+    {}
+
+    public function setOrganizationId(int $organizationId) : self{
+        $this->organizationId = $organizationId;
+        return $this;
+    }
+
+    public function getOrganizationId(): int {
+        return $this->organizationId;
+    }
+
+    public function setSubdomainId(int $subdomainId): self {
+        $this->subdomainId = $subdomainId;
+        return $this;
+    }
+
+    public function getSubdomainId(): int{
+        return $this->subdomainId;
+    }
+
+    public function setUrl(string $url): self {
+        $this->url = $url;
+        return $this;
+    }
+
+    public function getUrl(): string{
+        return $this->url;
+    }
+}

+ 8 - 1
src/Service/Mobyt/MobytService.php

@@ -35,7 +35,14 @@ class MobytService extends ApiRequestService
      */
     protected function connect(string $login, string $password): void
     {
-        $responseContent = $this->getContent('login', ['username' => $login, 'password' => $password]);
+        $responseContent = $this->getContent('login',[],
+            [
+                'headers' => [
+                    'Content-Type: application/json',
+                    'Authorization: Basic '. base64_encode($login.':'.$password),
+                ]
+            ]
+        );
         [$this->userId, $this->sessionKey] = explode(';', $responseContent);
     }
 

+ 23 - 16
src/Service/OnChange/Organization/OnSubdomainChange.php

@@ -3,23 +3,26 @@
 namespace App\Service\OnChange\Organization;
 
 use App\Entity\Organization\Subdomain;
+use App\Message\Command\MailerCommand;
 use App\Message\Command\Typo3\Typo3UpdateCommand;
 use App\Service\Access\Utils as AccessUtils;
+use App\Service\Mailer\Model\SubdomainChangeModel;
 use App\Service\MailHub;
 use App\Service\OnChange\OnChangeContext;
 use App\Service\OnChange\OnChangeDefault;
 use App\Service\Organization\Utils as OrganizationUtils;
 use App\Service\Typo3\BindFileService;
+use App\Tests\Service\OnChange\Organization\OnSubdomainChangeTest;
 use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Security\Core\Security;
 
 class OnSubdomainChange extends OnChangeDefault
 {
     public function __construct(
         private OrganizationUtils $organizationUtils,
-        private AccessUtils $accessUtils,
-        private MailHub $mailHub,
         private BindFileService $bindFileService,
-        private MessageBusInterface $messageBus
+        private MessageBusInterface $messageBus,
+        private Security $security
     ) {}
 
     /** @noinspection PhpParameterNameChangedDuringInheritanceInspection */
@@ -69,22 +72,26 @@ class OnSubdomainChange extends OnChangeDefault
     }
 
     /**
-     * @throws \Exception
+     * @param Subdomain $subdomain
+     * @see OnSubdomainChangeTest::testSendEmailAfterSubdomainChange()
      */
     public function sendEmailAfterSubdomainChange(Subdomain $subdomain): void
     {
-
-        $admin = $this->accessUtils->findAdminFor($subdomain->getOrganization());
-
-        $this->mailHub->sendAutomaticEmailToAdmin(
-            $subdomain->getOrganization(),
-            'Nouveau sous domaine: ' . $subdomain->getSubdomain(),
-            'subdomain',
-            [
-                'access' => $admin,
-                'subdomain' => $subdomain,
-                'url' => $this->organizationUtils->getOrganizationWebsite($subdomain->getOrganization())
-            ]
+        $this->messageBus->dispatch(
+            new MailerCommand($this->getMailModel($subdomain))
         );
     }
+
+    /**
+     * @param Subdomain $subdomain
+     * @return SubdomainChangeModel
+     * @see OnSubdomainChangeTest::testGetMailModel()
+     */
+    public function getMailModel(Subdomain $subdomain): SubdomainChangeModel{
+        return (new SubdomainChangeModel())
+            ->setSenderId($this->security->getUser()->getId())
+            ->setOrganizationId($subdomain->getOrganization()->getId())
+            ->setSubdomainId($subdomain->getId())
+            ->setUrl($this->organizationUtils->getOrganizationWebsite($subdomain->getOrganization()));
+    }
 }

+ 2 - 1
src/Service/Organization/OrganizationProfileCreator.php

@@ -67,7 +67,8 @@ class OrganizationProfileCreator
         $organizationProfile
             ->setId($organization->getId())
             ->setName($organization->getName())
-            ->setWebsite($this->organizationUtils->getOrganizationWebsite($organization));
+            ->setWebsite($this->organizationUtils->getOrganizationWebsite($organization))
+        ;
 
         return $organizationProfile;
     }

+ 41 - 0
src/Service/ServiceIterator/Mailer/BuilderIterator.php

@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\ServiceIterator\Mailer;
+
+use App\Service\Mailer\Model\MailerModelInterface;
+use App\Service\Mailer\Builder\BuilderInterface;
+use Exception;
+
+/**
+ * Permet d'itérer sur les services de build Mailer
+ */
+class BuilderIterator
+{
+    /**
+     * Pour l'injection des services, voir config/services.yaml, section 'TAG Services'
+     * @param iterable $builderServices
+     */
+    public function __construct(
+        private iterable $builderServices,
+    ) {}
+
+    /**
+     * Itère sur les services de build disponibles et
+     * retourne le premier qui supporte ce type de requête.
+     *
+     * @param string $type
+     * @return BuilderInterface
+     * @throws Exception
+     */
+    public function getBuilderFor(MailerModelInterface $mailerModel): BuilderInterface
+    {
+        /** @var BuilderInterface $builderServices */
+        foreach ($this->builderServices as $builderService){
+            if($builderService->support($mailerModel))
+                return $builderService;
+        }
+        throw new Exception('no builder service found for this operation');
+    }
+}

+ 19 - 0
src/Service/Utils/Environnement.php

@@ -0,0 +1,19 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Utils;
+
+use App\Enum\Utils\EnvironnementVarEnum;
+
+/**
+ * Classe Environnement qui permet de récupérer des variables d'environnement
+ */
+class Environnement
+{
+    public function get(string $name): string{
+        if(in_array($name, EnvironnementVarEnum::toArray(), true)){
+            return $_ENV[$name];
+        }
+        throw new \RuntimeException(sprintf('Undefined environment variable : %s', $name), 500);
+    }
+}

+ 13 - 0
src/Service/Utils/StringsUtils.php

@@ -28,6 +28,8 @@ class StringsUtils
      * @param string $string
      * @param string $sep
      * @return string
+     *
+     * @see StringsUtilsTest::testCamelToSnake()
      */
     public static function camelToSnake(string $string, string $sep = "_"): string {
         return strtolower(
@@ -38,4 +40,15 @@ class StringsUtils
             )
         );
     }
+
+    /**
+     * @param string $html
+     * @return string
+     *
+     * @see StringsUtilsTest::testConvertHtmlToText()
+     */
+    public function convertHtmlToText(string $html): string
+    {
+        return strip_tags(preg_replace('{<(head|style)\b.*?</\1>}is', '', $html));
+    }
 }

+ 1694 - 0
templates/assets/styles/foundation-emails.css

@@ -0,0 +1,1694 @@
+.wrapper {
+    width: 100%;
+}
+
+#outlook a {
+    padding: 0;
+}
+
+body {
+    width: 100% !important;
+    min-width: 100%;
+    -webkit-text-size-adjust: 100%;
+    -ms-text-size-adjust: 100%;
+    margin: 0;
+    Margin: 0;
+    padding: 0;
+    -moz-box-sizing: border-box;
+    -webkit-box-sizing: border-box;
+    box-sizing: border-box;
+}
+
+.ExternalClass {
+    width: 100%;
+}
+.ExternalClass,
+.ExternalClass p,
+.ExternalClass span,
+.ExternalClass font,
+.ExternalClass td,
+.ExternalClass th,
+.ExternalClass div {
+    line-height: 100%;
+}
+
+#backgroundTable {
+    margin: 0;
+    Margin: 0;
+    padding: 0;
+    width: 100% !important;
+    line-height: 100% !important;
+}
+
+img {
+    outline: none;
+    text-decoration: none;
+    -ms-interpolation-mode: bicubic;
+    width: auto;
+    max-width: 100%;
+    clear: both;
+    display: block;
+}
+
+center {
+    width: 100%;
+}
+
+a img {
+    border: none;
+}
+
+table {
+    border-spacing: 0;
+    border-collapse: collapse;
+}
+
+td, th {
+    word-wrap: break-word;
+    -webkit-hyphens: auto;
+    -moz-hyphens: auto;
+    hyphens: auto;
+    border-collapse: collapse !important;
+    -moz-box-sizing: border-box;
+    -webkit-box-sizing: border-box;
+    box-sizing: border-box;
+}
+
+table, tr, td, th {
+    padding: 0;
+    vertical-align: top;
+    text-align: left;
+}
+
+@media only screen {
+    html {
+        min-height: 100%;
+        background: #f3f3f3;
+    }
+}
+table.body {
+    background: #f3f3f3;
+    height: 100%;
+    width: 100%;
+}
+table.container {
+    background: #fefefe;
+    width: 580px;
+    margin: 0 auto;
+    Margin: 0 auto;
+    text-align: inherit;
+}
+table.row {
+    padding: 0;
+    width: 100%;
+    position: relative;
+}
+table.spacer {
+    width: 100%;
+}
+table.spacer td {
+    mso-line-height-rule: exactly;
+}
+
+table.container table.row {
+    display: table;
+}
+
+td.columns,
+td.column,
+th.columns,
+th.column {
+    margin: 0 auto;
+    Margin: 0 auto;
+    padding-left: 16px;
+    padding-bottom: 16px;
+}
+td.columns .column.first,
+td.columns .columns.first,
+td.column .column.first,
+td.column .columns.first,
+th.columns .column.first,
+th.columns .columns.first,
+th.column .column.first,
+th.column .columns.first {
+    padding-left: 0 !important;
+}
+td.columns .column.last,
+td.columns .columns.last,
+td.column .column.last,
+td.column .columns.last,
+th.columns .column.last,
+th.columns .columns.last,
+th.column .column.last,
+th.column .columns.last {
+    padding-right: 0 !important;
+}
+td.columns .column:not([class*=large-offset]),
+td.columns .columns:not([class*=large-offset]),
+td.column .column:not([class*=large-offset]),
+td.column .columns:not([class*=large-offset]),
+th.columns .column:not([class*=large-offset]),
+th.columns .columns:not([class*=large-offset]),
+th.column .column:not([class*=large-offset]),
+th.column .columns:not([class*=large-offset]) {
+    padding-left: 0 !important;
+    padding-right: 0 !important;
+}
+
+td.columns.last,
+td.column.last,
+th.columns.last,
+th.column.last {
+    padding-right: 16px;
+}
+
+td.columns table,
+td.column table,
+th.columns table,
+th.column table {
+    width: 100%;
+}
+td.columns table.button,
+td.column table.button,
+th.columns table.button,
+th.column table.button {
+    width: auto;
+}
+td.columns table.button.expand, td.columns table.button.expanded,
+td.column table.button.expand,
+td.column table.button.expanded,
+th.columns table.button.expand,
+th.columns table.button.expanded,
+th.column table.button.expand,
+th.column table.button.expanded {
+    width: 100%;
+}
+
+td.large-1,
+th.large-1 {
+    width: 32.3333333333px;
+    padding-left: 8px;
+    padding-right: 8px;
+}
+
+td.large-1.first,
+th.large-1.first {
+    padding-left: 16px;
+}
+
+td.large-1.last,
+th.large-1.last {
+    padding-right: 16px;
+}
+
+.collapse > tbody > tr > td.large-1:not([class*=large-offset]),
+.collapse > tbody > tr > th.large-1:not([class*=large-offset]) {
+    padding-right: 0;
+    padding-left: 0;
+    width: 48.3333333333px;
+}
+.collapse > tbody > tr td.large-1.first,
+.collapse > tbody > tr th.large-1.first,
+.collapse > tbody > tr td.large-1.last,
+.collapse > tbody > tr th.large-1.last {
+    width: 56.3333333333px;
+}
+
+.body .columns td.large-1,
+.body .column td.large-1,
+.body .columns th.large-1,
+.body .column th.large-1 {
+    width: 8.333333%;
+}
+
+td.large-2,
+th.large-2 {
+    width: 80.6666666667px;
+    padding-left: 8px;
+    padding-right: 8px;
+}
+
+td.large-2.first,
+th.large-2.first {
+    padding-left: 16px;
+}
+
+td.large-2.last,
+th.large-2.last {
+    padding-right: 16px;
+}
+
+.collapse > tbody > tr > td.large-2:not([class*=large-offset]),
+.collapse > tbody > tr > th.large-2:not([class*=large-offset]) {
+    padding-right: 0;
+    padding-left: 0;
+    width: 96.6666666667px;
+}
+.collapse > tbody > tr td.large-2.first,
+.collapse > tbody > tr th.large-2.first,
+.collapse > tbody > tr td.large-2.last,
+.collapse > tbody > tr th.large-2.last {
+    width: 104.6666666667px;
+}
+
+.body .columns td.large-2,
+.body .column td.large-2,
+.body .columns th.large-2,
+.body .column th.large-2 {
+    width: 16.666666%;
+}
+
+td.large-3,
+th.large-3 {
+    width: 129px;
+    padding-left: 8px;
+    padding-right: 8px;
+}
+
+td.large-3.first,
+th.large-3.first {
+    padding-left: 16px;
+}
+
+td.large-3.last,
+th.large-3.last {
+    padding-right: 16px;
+}
+
+.collapse > tbody > tr > td.large-3:not([class*=large-offset]),
+.collapse > tbody > tr > th.large-3:not([class*=large-offset]) {
+    padding-right: 0;
+    padding-left: 0;
+    width: 145px;
+}
+.collapse > tbody > tr td.large-3.first,
+.collapse > tbody > tr th.large-3.first,
+.collapse > tbody > tr td.large-3.last,
+.collapse > tbody > tr th.large-3.last {
+    width: 153px;
+}
+
+.body .columns td.large-3,
+.body .column td.large-3,
+.body .columns th.large-3,
+.body .column th.large-3 {
+    width: 25%;
+}
+
+td.large-4,
+th.large-4 {
+    width: 177.3333333333px;
+    padding-left: 8px;
+    padding-right: 8px;
+}
+
+td.large-4.first,
+th.large-4.first {
+    padding-left: 16px;
+}
+
+td.large-4.last,
+th.large-4.last {
+    padding-right: 16px;
+}
+
+.collapse > tbody > tr > td.large-4:not([class*=large-offset]),
+.collapse > tbody > tr > th.large-4:not([class*=large-offset]) {
+    padding-right: 0;
+    padding-left: 0;
+    width: 193.3333333333px;
+}
+.collapse > tbody > tr td.large-4.first,
+.collapse > tbody > tr th.large-4.first,
+.collapse > tbody > tr td.large-4.last,
+.collapse > tbody > tr th.large-4.last {
+    width: 201.3333333333px;
+}
+
+.body .columns td.large-4,
+.body .column td.large-4,
+.body .columns th.large-4,
+.body .column th.large-4 {
+    width: 33.333333%;
+}
+
+td.large-5,
+th.large-5 {
+    width: 225.6666666667px;
+    padding-left: 8px;
+    padding-right: 8px;
+}
+
+td.large-5.first,
+th.large-5.first {
+    padding-left: 16px;
+}
+
+td.large-5.last,
+th.large-5.last {
+    padding-right: 16px;
+}
+
+.collapse > tbody > tr > td.large-5:not([class*=large-offset]),
+.collapse > tbody > tr > th.large-5:not([class*=large-offset]) {
+    padding-right: 0;
+    padding-left: 0;
+    width: 241.6666666667px;
+}
+.collapse > tbody > tr td.large-5.first,
+.collapse > tbody > tr th.large-5.first,
+.collapse > tbody > tr td.large-5.last,
+.collapse > tbody > tr th.large-5.last {
+    width: 249.6666666667px;
+}
+
+.body .columns td.large-5,
+.body .column td.large-5,
+.body .columns th.large-5,
+.body .column th.large-5 {
+    width: 41.666666%;
+}
+
+td.large-6,
+th.large-6 {
+    width: 274px;
+    padding-left: 8px;
+    padding-right: 8px;
+}
+
+td.large-6.first,
+th.large-6.first {
+    padding-left: 16px;
+}
+
+td.large-6.last,
+th.large-6.last {
+    padding-right: 16px;
+}
+
+.collapse > tbody > tr > td.large-6:not([class*=large-offset]),
+.collapse > tbody > tr > th.large-6:not([class*=large-offset]) {
+    padding-right: 0;
+    padding-left: 0;
+    width: 290px;
+}
+.collapse > tbody > tr td.large-6.first,
+.collapse > tbody > tr th.large-6.first,
+.collapse > tbody > tr td.large-6.last,
+.collapse > tbody > tr th.large-6.last {
+    width: 298px;
+}
+
+.body .columns td.large-6,
+.body .column td.large-6,
+.body .columns th.large-6,
+.body .column th.large-6 {
+    width: 50%;
+}
+
+td.large-7,
+th.large-7 {
+    width: 322.3333333333px;
+    padding-left: 8px;
+    padding-right: 8px;
+}
+
+td.large-7.first,
+th.large-7.first {
+    padding-left: 16px;
+}
+
+td.large-7.last,
+th.large-7.last {
+    padding-right: 16px;
+}
+
+.collapse > tbody > tr > td.large-7:not([class*=large-offset]),
+.collapse > tbody > tr > th.large-7:not([class*=large-offset]) {
+    padding-right: 0;
+    padding-left: 0;
+    width: 338.3333333333px;
+}
+.collapse > tbody > tr td.large-7.first,
+.collapse > tbody > tr th.large-7.first,
+.collapse > tbody > tr td.large-7.last,
+.collapse > tbody > tr th.large-7.last {
+    width: 346.3333333333px;
+}
+
+.body .columns td.large-7,
+.body .column td.large-7,
+.body .columns th.large-7,
+.body .column th.large-7 {
+    width: 58.333333%;
+}
+
+td.large-8,
+th.large-8 {
+    width: 370.6666666667px;
+    padding-left: 8px;
+    padding-right: 8px;
+}
+
+td.large-8.first,
+th.large-8.first {
+    padding-left: 16px;
+}
+
+td.large-8.last,
+th.large-8.last {
+    padding-right: 16px;
+}
+
+.collapse > tbody > tr > td.large-8:not([class*=large-offset]),
+.collapse > tbody > tr > th.large-8:not([class*=large-offset]) {
+    padding-right: 0;
+    padding-left: 0;
+    width: 386.6666666667px;
+}
+.collapse > tbody > tr td.large-8.first,
+.collapse > tbody > tr th.large-8.first,
+.collapse > tbody > tr td.large-8.last,
+.collapse > tbody > tr th.large-8.last {
+    width: 394.6666666667px;
+}
+
+.body .columns td.large-8,
+.body .column td.large-8,
+.body .columns th.large-8,
+.body .column th.large-8 {
+    width: 66.666666%;
+}
+
+td.large-9,
+th.large-9 {
+    width: 419px;
+    padding-left: 8px;
+    padding-right: 8px;
+}
+
+td.large-9.first,
+th.large-9.first {
+    padding-left: 16px;
+}
+
+td.large-9.last,
+th.large-9.last {
+    padding-right: 16px;
+}
+
+.collapse > tbody > tr > td.large-9:not([class*=large-offset]),
+.collapse > tbody > tr > th.large-9:not([class*=large-offset]) {
+    padding-right: 0;
+    padding-left: 0;
+    width: 435px;
+}
+.collapse > tbody > tr td.large-9.first,
+.collapse > tbody > tr th.large-9.first,
+.collapse > tbody > tr td.large-9.last,
+.collapse > tbody > tr th.large-9.last {
+    width: 443px;
+}
+
+.body .columns td.large-9,
+.body .column td.large-9,
+.body .columns th.large-9,
+.body .column th.large-9 {
+    width: 75%;
+}
+
+td.large-10,
+th.large-10 {
+    width: 467.3333333333px;
+    padding-left: 8px;
+    padding-right: 8px;
+}
+
+td.large-10.first,
+th.large-10.first {
+    padding-left: 16px;
+}
+
+td.large-10.last,
+th.large-10.last {
+    padding-right: 16px;
+}
+
+.collapse > tbody > tr > td.large-10:not([class*=large-offset]),
+.collapse > tbody > tr > th.large-10:not([class*=large-offset]) {
+    padding-right: 0;
+    padding-left: 0;
+    width: 483.3333333333px;
+}
+.collapse > tbody > tr td.large-10.first,
+.collapse > tbody > tr th.large-10.first,
+.collapse > tbody > tr td.large-10.last,
+.collapse > tbody > tr th.large-10.last {
+    width: 491.3333333333px;
+}
+
+.body .columns td.large-10,
+.body .column td.large-10,
+.body .columns th.large-10,
+.body .column th.large-10 {
+    width: 83.333333%;
+}
+
+td.large-11,
+th.large-11 {
+    width: 515.6666666667px;
+    padding-left: 8px;
+    padding-right: 8px;
+}
+
+td.large-11.first,
+th.large-11.first {
+    padding-left: 16px;
+}
+
+td.large-11.last,
+th.large-11.last {
+    padding-right: 16px;
+}
+
+.collapse > tbody > tr > td.large-11:not([class*=large-offset]),
+.collapse > tbody > tr > th.large-11:not([class*=large-offset]) {
+    padding-right: 0;
+    padding-left: 0;
+    width: 531.6666666667px;
+}
+.collapse > tbody > tr td.large-11.first,
+.collapse > tbody > tr th.large-11.first,
+.collapse > tbody > tr td.large-11.last,
+.collapse > tbody > tr th.large-11.last {
+    width: 539.6666666667px;
+}
+
+.body .columns td.large-11,
+.body .column td.large-11,
+.body .columns th.large-11,
+.body .column th.large-11 {
+    width: 91.666666%;
+}
+
+td.large-12,
+th.large-12 {
+    width: 564px;
+    padding-left: 8px;
+    padding-right: 8px;
+}
+
+td.large-12.first,
+th.large-12.first {
+    padding-left: 16px;
+}
+
+td.large-12.last,
+th.large-12.last {
+    padding-right: 16px;
+}
+
+.collapse > tbody > tr > td.large-12:not([class*=large-offset]),
+.collapse > tbody > tr > th.large-12:not([class*=large-offset]) {
+    padding-right: 0;
+    padding-left: 0;
+    width: 580px;
+}
+.collapse > tbody > tr td.large-12.first,
+.collapse > tbody > tr th.large-12.first,
+.collapse > tbody > tr td.large-12.last,
+.collapse > tbody > tr th.large-12.last {
+    width: 588px;
+}
+
+.body .columns td.large-12,
+.body .column td.large-12,
+.body .columns th.large-12,
+.body .column th.large-12 {
+    width: 100%;
+}
+
+td.large-offset-1,
+td.large-offset-1.first,
+td.large-offset-1.last,
+th.large-offset-1,
+th.large-offset-1.first,
+th.large-offset-1.last {
+    padding-left: 64.3333333333px;
+}
+
+td.large-offset-2,
+td.large-offset-2.first,
+td.large-offset-2.last,
+th.large-offset-2,
+th.large-offset-2.first,
+th.large-offset-2.last {
+    padding-left: 112.6666666667px;
+}
+
+td.large-offset-3,
+td.large-offset-3.first,
+td.large-offset-3.last,
+th.large-offset-3,
+th.large-offset-3.first,
+th.large-offset-3.last {
+    padding-left: 161px;
+}
+
+td.large-offset-4,
+td.large-offset-4.first,
+td.large-offset-4.last,
+th.large-offset-4,
+th.large-offset-4.first,
+th.large-offset-4.last {
+    padding-left: 209.3333333333px;
+}
+
+td.large-offset-5,
+td.large-offset-5.first,
+td.large-offset-5.last,
+th.large-offset-5,
+th.large-offset-5.first,
+th.large-offset-5.last {
+    padding-left: 257.6666666667px;
+}
+
+td.large-offset-6,
+td.large-offset-6.first,
+td.large-offset-6.last,
+th.large-offset-6,
+th.large-offset-6.first,
+th.large-offset-6.last {
+    padding-left: 306px;
+}
+
+td.large-offset-7,
+td.large-offset-7.first,
+td.large-offset-7.last,
+th.large-offset-7,
+th.large-offset-7.first,
+th.large-offset-7.last {
+    padding-left: 354.3333333333px;
+}
+
+td.large-offset-8,
+td.large-offset-8.first,
+td.large-offset-8.last,
+th.large-offset-8,
+th.large-offset-8.first,
+th.large-offset-8.last {
+    padding-left: 402.6666666667px;
+}
+
+td.large-offset-9,
+td.large-offset-9.first,
+td.large-offset-9.last,
+th.large-offset-9,
+th.large-offset-9.first,
+th.large-offset-9.last {
+    padding-left: 451px;
+}
+
+td.large-offset-10,
+td.large-offset-10.first,
+td.large-offset-10.last,
+th.large-offset-10,
+th.large-offset-10.first,
+th.large-offset-10.last {
+    padding-left: 499.3333333333px;
+}
+
+td.large-offset-11,
+td.large-offset-11.first,
+td.large-offset-11.last,
+th.large-offset-11,
+th.large-offset-11.first,
+th.large-offset-11.last {
+    padding-left: 547.6666666667px;
+}
+
+td.expander,
+th.expander {
+    visibility: hidden;
+    width: 0;
+    padding: 0 !important;
+}
+
+table.container.radius {
+    border-radius: 0;
+    border-collapse: separate;
+}
+
+.block-grid {
+    width: 100%;
+    max-width: 580px;
+}
+.block-grid td {
+    display: inline-block;
+    padding: 8px;
+}
+
+.up-2 td {
+    width: 274px !important;
+}
+
+.up-3 td {
+    width: 177px !important;
+}
+
+.up-4 td {
+    width: 129px !important;
+}
+
+.up-5 td {
+    width: 100px !important;
+}
+
+.up-6 td {
+    width: 80px !important;
+}
+
+.up-7 td {
+    width: 66px !important;
+}
+
+.up-8 td {
+    width: 56px !important;
+}
+
+table.text-center,
+th.text-center,
+td.text-center,
+h1.text-center,
+h2.text-center,
+h3.text-center,
+h4.text-center,
+h5.text-center,
+h6.text-center,
+p.text-center,
+span.text-center {
+    text-align: center;
+}
+table.text-left,
+th.text-left,
+td.text-left,
+h1.text-left,
+h2.text-left,
+h3.text-left,
+h4.text-left,
+h5.text-left,
+h6.text-left,
+p.text-left,
+span.text-left {
+    text-align: left;
+}
+table.text-right,
+th.text-right,
+td.text-right,
+h1.text-right,
+h2.text-right,
+h3.text-right,
+h4.text-right,
+h5.text-right,
+h6.text-right,
+p.text-right,
+span.text-right {
+    text-align: right;
+}
+
+span.text-center {
+    display: block;
+    width: 100%;
+    text-align: center;
+}
+
+@media only screen and (max-width: 596px) {
+    .small-float-center {
+        margin: 0 auto !important;
+        float: none !important;
+        text-align: center !important;
+    }
+
+    .small-text-center {
+        text-align: center !important;
+    }
+
+    .small-text-left {
+        text-align: left !important;
+    }
+
+    .small-text-right {
+        text-align: right !important;
+    }
+}
+img.float-left {
+    float: left;
+    text-align: left;
+}
+
+img.float-right {
+    float: right;
+    text-align: right;
+}
+
+img.float-center,
+img.text-center {
+    margin: 0 auto;
+    Margin: 0 auto;
+    float: none;
+    text-align: center;
+}
+
+table.float-center,
+td.float-center,
+th.float-center {
+    margin: 0 auto;
+    Margin: 0 auto;
+    float: none;
+    text-align: center;
+}
+
+td.columns[valign=bottom],
+td.column[valign=bottom],
+th.columns[valign=bottom],
+th.column[valign=bottom] {
+    vertical-align: bottom;
+}
+
+td.columns[valign=middle],
+td.column[valign=middle],
+th.columns[valign=middle],
+th.column[valign=middle] {
+    vertical-align: middle;
+}
+
+.hide-for-large {
+    display: none;
+    mso-hide: all;
+    overflow: hidden;
+    max-height: 0;
+    font-size: 0;
+    width: 0;
+    line-height: 0;
+}
+@media only screen and (max-width: 596px) {
+    .hide-for-large {
+        display: block !important;
+        width: auto !important;
+        overflow: visible !important;
+        max-height: none !important;
+        font-size: inherit !important;
+        line-height: inherit !important;
+    }
+}
+
+table.body table.container .hide-for-large * {
+    mso-hide: all;
+}
+
+@media only screen and (max-width: 596px) {
+    table.body table.container .hide-for-large,
+    table.body table.container .row.hide-for-large {
+        display: table !important;
+        width: 100% !important;
+    }
+}
+
+@media only screen and (max-width: 596px) {
+    table.body table.container .callout-inner.hide-for-large {
+        display: table-cell !important;
+        width: 100% !important;
+    }
+}
+
+@media only screen and (max-width: 596px) {
+    table.body table.container .show-for-large {
+        display: none !important;
+        width: 0;
+        mso-hide: all;
+        overflow: hidden;
+    }
+}
+
+body,
+table.body,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+td,
+th {
+    color: #0a0a0a;
+    font-family: Helvetica, Arial, sans-serif;
+    font-weight: normal;
+    padding: 0;
+    margin: 0;
+    Margin: 0;
+    text-align: left;
+    line-height: 130%;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+    color: inherit;
+    word-wrap: normal;
+    font-family: Helvetica, Arial, sans-serif;
+    font-weight: normal;
+    margin-bottom: 10px;
+    Margin-bottom: 10px;
+}
+
+h1 {
+    font-size: 34px;
+}
+
+h2 {
+    font-size: 30px;
+}
+
+h3 {
+    font-size: 28px;
+}
+
+h4 {
+    font-size: 24px;
+}
+
+h5 {
+    font-size: 19px;
+}
+
+h6 {
+    font-size: 18px;
+}
+
+body,
+table.body,
+p,
+td,
+th {
+    font-size: 16px;
+    line-height: 130%;
+}
+
+p {
+    margin-bottom: 10px;
+    Margin-bottom: 10px;
+}
+p.lead {
+    font-size: 20px;
+    line-height: 160%;
+}
+p.subheader {
+    margin-top: 4px;
+    margin-bottom: 8px;
+    Margin-top: 4px;
+    Margin-bottom: 8px;
+    font-weight: normal;
+    line-height: 1.4;
+    color: #8a8a8a;
+}
+p a {
+    margin: default;
+    Margin: default;
+}
+
+.text-xs {
+    font-size: 11.1111111111px;
+}
+
+.text-sm {
+    font-size: 13.3333333333px;
+}
+
+.text-lg {
+    font-size: 19.2px;
+}
+
+.text-xl {
+    font-size: 23.04px;
+}
+
+.text-xxl {
+    font-size: 27.648px;
+}
+
+small,
+.small {
+    font-size: 80%;
+    color: #cacaca;
+}
+
+a {
+    color: #2199e8;
+    text-decoration: none;
+    font-family: Helvetica, Arial, sans-serif;
+    font-weight: normal;
+    padding: 0;
+    text-align: left;
+    line-height: 130%;
+}
+a:hover {
+    color: #147dc2;
+}
+a:active {
+    color: #147dc2;
+}
+a:visited {
+    color: #2199e8;
+}
+
+h1 a,
+h1 a:visited,
+h2 a,
+h2 a:visited,
+h3 a,
+h3 a:visited,
+h4 a,
+h4 a:visited,
+h5 a,
+h5 a:visited,
+h6 a,
+h6 a:visited {
+    color: #2199e8;
+}
+
+pre {
+    background: #f3f3f3;
+    margin: 30px 0;
+    Margin: 30px 0;
+}
+pre code {
+    color: #cacaca;
+}
+pre code span.callout {
+    color: #8a8a8a;
+    font-weight: bold;
+}
+pre code span.callout-strong {
+    color: #ff6908;
+    font-weight: bold;
+}
+
+td.columns table.hr table, td.column table.hr table, th.columns table.hr table, th.column table.hr table,
+td.columns table.h-line table,
+td.column table.h-line table,
+th.columns table.h-line table,
+th.column table.h-line table {
+    width: auto;
+}
+
+table.hr th,
+table.h-line th {
+    padding-bottom: 20px;
+    text-align: center;
+}
+table.hr table,
+table.h-line table {
+    display: inline-block;
+    margin: 0;
+    Margin: 0;
+}
+table.hr th,
+table.h-line th {
+    width: 580px;
+    height: 0;
+    padding-top: 20px;
+    clear: both;
+    border-top: 0;
+    border-right: 0;
+    border-bottom: 1px solid #0a0a0a;
+    border-left: 0;
+    font-size: 0;
+    line-height: 0;
+}
+
+.stat {
+    font-size: 40px;
+    line-height: 1;
+}
+p + .stat {
+    margin-top: -16px;
+    Margin-top: -16px;
+}
+
+span.preheader {
+    display: none !important;
+    visibility: hidden;
+    mso-hide: all !important;
+    font-size: 1px;
+    color: #f3f3f3;
+    line-height: 1px;
+    max-height: 0px;
+    max-width: 0px;
+    opacity: 0;
+    overflow: hidden;
+}
+
+@media only screen {
+    a[x-apple-data-detectors] {
+        color: inherit !important;
+        text-decoration: none !important;
+        font-size: inherit !important;
+        font-family: inherit !important;
+        font-weight: inherit !important;
+        line-height: inherit !important;
+    }
+}
+table.button {
+    width: auto;
+    margin: 0 0 16px 0;
+    Margin: 0 0 16px 0;
+}
+table.button table td {
+    text-align: left;
+    color: #fefefe;
+    background: #2199e8;
+    border: 2px solid #2199e8;
+}
+table.button table td a {
+    font-family: Helvetica, Arial, sans-serif;
+    font-size: 16px;
+    font-weight: bold;
+    color: #fefefe;
+    text-decoration: none;
+    text-align: left;
+    display: inline-block;
+    padding: 8px 16px 8px 16px;
+    border: 0 solid #2199e8;
+    border-radius: 3px;
+}
+table.button.radius table td {
+    border-radius: 3px;
+    border: none;
+}
+table.button.rounded table td {
+    border-radius: 500px;
+    border: none;
+}
+
+table.button:not(.expand):not(.expanded) table {
+    width: auto;
+}
+
+table.button:hover table tr td a,
+table.button:active table tr td a,
+table.button table tr td a:visited,
+table.button.tiny:hover table tr td a,
+table.button.tiny:active table tr td a,
+table.button.tiny table tr td a:visited,
+table.button.small:hover table tr td a,
+table.button.small:active table tr td a,
+table.button.small table tr td a:visited,
+table.button.large:hover table tr td a,
+table.button.large:active table tr td a,
+table.button.large table tr td a:visited {
+    color: #fefefe;
+}
+
+table.button.tiny table td,
+table.button.tiny table a {
+    padding: 4px 8px 4px 8px;
+}
+table.button.tiny table a {
+    font-size: 10px;
+    font-weight: normal;
+}
+
+table.button.small table td,
+table.button.small table a {
+    padding: 5px 10px 5px 10px;
+    font-size: 12px;
+}
+
+table.button.large table a {
+    padding: 10px 20px 10px 20px;
+    font-size: 20px;
+}
+
+table.button.expand,
+table.button.expanded {
+    width: 100%;
+}
+table.button.expand table,
+table.button.expanded table {
+    width: 100%;
+}
+table.button.expand table a,
+table.button.expanded table a {
+    text-align: center;
+    width: 100%;
+    padding-left: 0;
+    padding-right: 0;
+}
+table.button.expand center,
+table.button.expanded center {
+    min-width: 0;
+}
+
+table.button:hover table td,
+table.button:visited table td,
+table.button:active table td {
+    background: #147dc2;
+    color: #fefefe;
+}
+
+table.button:hover table a,
+table.button:visited table a,
+table.button:active table a {
+    border: 0 solid #147dc2;
+}
+
+table.button.secondary table td {
+    background: #777777;
+    color: #fefefe;
+    border: 0px solid #777777;
+}
+table.button.secondary table a {
+    color: #fefefe;
+    border: 0 solid #777777;
+}
+
+table.button.secondary:hover table td {
+    background: #919191;
+    color: #fefefe;
+}
+table.button.secondary:hover table a {
+    border: 0 solid #919191;
+}
+
+table.button.secondary:hover table td a {
+    color: #fefefe;
+}
+
+table.button.secondary:active table td a {
+    color: #fefefe;
+}
+
+table.button.secondary table td a:visited {
+    color: #fefefe;
+}
+
+table.button.success table td {
+    background: #3adb76;
+    border: 0px solid #3adb76;
+}
+table.button.success table a {
+    border: 0 solid #3adb76;
+}
+
+table.button.success:hover table td {
+    background: #23bf5d;
+}
+table.button.success:hover table a {
+    border: 0 solid #23bf5d;
+}
+
+table.button.alert table td {
+    background: #ec5840;
+    border: 0px solid #ec5840;
+}
+table.button.alert table a {
+    border: 0 solid #ec5840;
+}
+
+table.button.alert:hover table td {
+    background: #e23317;
+}
+table.button.alert:hover table a {
+    border: 0 solid #e23317;
+}
+
+table.button.warning table td {
+    background: #ffae00;
+    border: 0px solid #ffae00;
+}
+table.button.warning table a {
+    border: 0px solid #ffae00;
+}
+
+table.button.warning:hover table td {
+    background: #cc8b00;
+}
+table.button.warning:hover table a {
+    border: 0px solid #cc8b00;
+}
+
+table.callout {
+    margin-bottom: 16px;
+    Margin-bottom: 16px;
+}
+
+th.callout-inner {
+    width: 100%;
+    border: 1px solid #cbcbcb;
+    padding: 10px;
+    background: #fefefe;
+}
+th.callout-inner.primary {
+    background: #def0fc;
+    border: 1px solid #0f5f94;
+    color: #0a0a0a;
+}
+th.callout-inner.secondary {
+    background: #ebebeb;
+    border: 1px solid #444444;
+    color: #0a0a0a;
+}
+th.callout-inner.success {
+    background: #e1faea;
+    border: 1px solid #1b9448;
+    color: #0a0a0a;
+}
+th.callout-inner.warning {
+    background: #fff3d9;
+    border: 1px solid #996800;
+    color: #0a0a0a;
+}
+th.callout-inner.alert {
+    background: #fce6e2;
+    border: 1px solid #b42912;
+    color: #0a0a0a;
+}
+
+.thumbnail {
+    border: solid 4px #fefefe;
+    box-shadow: 0 0 0 1px rgba(10, 10, 10, 0.2);
+    display: inline-block;
+    line-height: 0;
+    max-width: 100%;
+    transition: box-shadow 200ms ease-out;
+    border-radius: 3px;
+    margin-bottom: 16px;
+}
+.thumbnail:hover, .thumbnail:focus {
+    box-shadow: 0 0 6px 1px rgba(33, 153, 232, 0.5);
+}
+
+table.menu {
+    width: 580px;
+}
+table.menu td.menu-item,
+table.menu th.menu-item {
+    padding-top: 10px;
+    padding-right: 10px;
+    padding-bottom: 10px;
+    padding-left: 10px;
+}
+table.menu td.menu-item a,
+table.menu th.menu-item a {
+    color: #2199e8;
+}
+
+table.menu.vertical td.menu-item,
+table.menu.vertical th.menu-item {
+    padding-top: 10px;
+    padding-right: 0;
+    padding-bottom: 10px;
+    padding-left: 10px;
+    display: block;
+}
+table.menu.vertical td.menu-item a,
+table.menu.vertical th.menu-item a {
+    width: 100%;
+}
+table.menu.vertical td.menu-item table.menu.vertical td.menu-item,
+table.menu.vertical td.menu-item table.menu.vertical th.menu-item,
+table.menu.vertical th.menu-item table.menu.vertical td.menu-item,
+table.menu.vertical th.menu-item table.menu.vertical th.menu-item {
+    padding-left: 10px;
+}
+
+table.menu.text-center a {
+    text-align: center;
+}
+
+.menu[align=center] {
+    width: auto;
+}
+
+.menu[align=center] tr {
+    text-align: center;
+}
+
+.menu:not(.float-center) .menu-item:first-child {
+    padding-left: 0 !important;
+}
+.menu:not(.float-center) .menu-item:last-child {
+    padding-right: 0 !important;
+}
+
+.menu.vertical .menu-item {
+    padding-left: 0 !important;
+    padding-right: 0 !important;
+}
+
+@media only screen and (max-width: 596px) {
+    .menu.small-vertical .menu-item {
+        padding-left: 0 !important;
+        padding-right: 0 !important;
+    }
+}
+body.outlook p {
+    display: inline !important;
+}
+
+@media only screen and (max-width: 596px) {
+    table.body img {
+        width: auto;
+        height: auto;
+    }
+
+    table.body center {
+        min-width: 0 !important;
+    }
+
+    table.body .container {
+        width: 95% !important;
+    }
+
+    table.body .columns,
+    table.body .column {
+        height: auto !important;
+        -moz-box-sizing: border-box;
+        -webkit-box-sizing: border-box;
+        box-sizing: border-box;
+        padding-left: 16px !important;
+        padding-right: 16px !important;
+    }
+
+    table.body .collapse > tbody > tr > .columns, table.body .collapse > tbody > tr > .column {
+        padding-left: 0 !important;
+        padding-right: 0 !important;
+    }
+
+    td.small-1,
+    th.small-1 {
+        display: inline-block !important;
+        width: 8.333333% !important;
+    }
+
+    td.small-2,
+    th.small-2 {
+        display: inline-block !important;
+        width: 16.666666% !important;
+    }
+
+    td.small-3,
+    th.small-3 {
+        display: inline-block !important;
+        width: 25% !important;
+    }
+
+    td.small-4,
+    th.small-4 {
+        display: inline-block !important;
+        width: 33.333333% !important;
+    }
+
+    td.small-5,
+    th.small-5 {
+        display: inline-block !important;
+        width: 41.666666% !important;
+    }
+
+    td.small-6,
+    th.small-6 {
+        display: inline-block !important;
+        width: 50% !important;
+    }
+
+    td.small-7,
+    th.small-7 {
+        display: inline-block !important;
+        width: 58.333333% !important;
+    }
+
+    td.small-8,
+    th.small-8 {
+        display: inline-block !important;
+        width: 66.666666% !important;
+    }
+
+    td.small-9,
+    th.small-9 {
+        display: inline-block !important;
+        width: 75% !important;
+    }
+
+    td.small-10,
+    th.small-10 {
+        display: inline-block !important;
+        width: 83.333333% !important;
+    }
+
+    td.small-11,
+    th.small-11 {
+        display: inline-block !important;
+        width: 91.666666% !important;
+    }
+
+    td.small-12,
+    th.small-12 {
+        display: inline-block !important;
+        width: 100% !important;
+    }
+
+    .columns td.small-12,
+    .column td.small-12,
+    .columns th.small-12,
+    .column th.small-12 {
+        display: block !important;
+        width: 100% !important;
+    }
+
+    table.body td.small-offset-1,
+    table.body th.small-offset-1 {
+        margin-left: 8.333333% !important;
+        Margin-left: 8.333333% !important;
+    }
+
+    table.body td.small-offset-2,
+    table.body th.small-offset-2 {
+        margin-left: 16.666666% !important;
+        Margin-left: 16.666666% !important;
+    }
+
+    table.body td.small-offset-3,
+    table.body th.small-offset-3 {
+        margin-left: 25% !important;
+        Margin-left: 25% !important;
+    }
+
+    table.body td.small-offset-4,
+    table.body th.small-offset-4 {
+        margin-left: 33.333333% !important;
+        Margin-left: 33.333333% !important;
+    }
+
+    table.body td.small-offset-5,
+    table.body th.small-offset-5 {
+        margin-left: 41.666666% !important;
+        Margin-left: 41.666666% !important;
+    }
+
+    table.body td.small-offset-6,
+    table.body th.small-offset-6 {
+        margin-left: 50% !important;
+        Margin-left: 50% !important;
+    }
+
+    table.body td.small-offset-7,
+    table.body th.small-offset-7 {
+        margin-left: 58.333333% !important;
+        Margin-left: 58.333333% !important;
+    }
+
+    table.body td.small-offset-8,
+    table.body th.small-offset-8 {
+        margin-left: 66.666666% !important;
+        Margin-left: 66.666666% !important;
+    }
+
+    table.body td.small-offset-9,
+    table.body th.small-offset-9 {
+        margin-left: 75% !important;
+        Margin-left: 75% !important;
+    }
+
+    table.body td.small-offset-10,
+    table.body th.small-offset-10 {
+        margin-left: 83.333333% !important;
+        Margin-left: 83.333333% !important;
+    }
+
+    table.body td.small-offset-11,
+    table.body th.small-offset-11 {
+        margin-left: 91.666666% !important;
+        Margin-left: 91.666666% !important;
+    }
+
+    table.body table.columns td.expander,
+    table.body table.columns th.expander {
+        display: none !important;
+    }
+
+    table.body .right-text-pad,
+    table.body .text-pad-right {
+        padding-left: 10px !important;
+    }
+
+    table.body .left-text-pad,
+    table.body .text-pad-left {
+        padding-right: 10px !important;
+    }
+
+    table.menu {
+        width: 100% !important;
+    }
+    table.menu td,
+    table.menu th {
+        width: auto !important;
+        display: inline-block !important;
+    }
+    table.menu.vertical td,
+    table.menu.vertical th, table.menu.small-vertical td,
+    table.menu.small-vertical th {
+        display: block !important;
+    }
+
+    table.menu[align=center] {
+        width: auto !important;
+    }
+
+    table.button.small-expand,
+    table.button.small-expanded {
+        width: 100% !important;
+    }
+    table.button.small-expand table,
+    table.button.small-expanded table {
+        width: 100%;
+    }
+    table.button.small-expand table a,
+    table.button.small-expanded table a {
+        text-align: center !important;
+        width: 100% !important;
+        padding-left: 0 !important;
+        padding-right: 0 !important;
+    }
+    table.button.small-expand center,
+    table.button.small-expanded center {
+        min-width: 0;
+    }
+
+    th.callout-inner {
+        padding: 10px !important;
+    }
+}

+ 0 - 12
templates/base.html.twig

@@ -1,12 +0,0 @@
-<!DOCTYPE html>
-<html>
-    <head>
-        <meta charset="UTF-8">
-        <title>{% block title %}Welcome!{% endblock %}</title>
-        {% block stylesheets %}{% endblock %}
-    </head>
-    <body>
-        {% block body %}{% endblock %}
-        {% block javascripts %}{% endblock %}
-    </body>
-</html>

+ 99 - 0
templates/emails/base.html.twig

@@ -0,0 +1,99 @@
+{% apply inky_to_html|inline_css(source('@styles/foundation-emails.css'))|inline_css %}
+
+    <style>
+        {% block style %}
+        .container *{
+            font-family: Arial;
+        }
+        .white{
+            color: #FFFFFF !important;
+        }
+        .black{
+            color: #000000 !important;
+        }
+        .container{
+            border: 2px solid #324250;
+            border-radius: 5px;
+            margin-top: 20px;
+        }
+        .header{
+            background: #324250;
+        }
+        .footer{
+            background: #1ead8f;
+            padding: 10px;
+            color: #FFFFFF;
+            font-weight: bold;
+        }
+        {% endblock %}
+    </style>
+
+    <container>
+        {% block header %}
+            <row>
+                <columns small="12" class="header">
+                    <spacer size="10"></spacer>
+                    <p class="white">{{ organization.name }}</p>
+                </columns>
+            </row>
+        {% endblock %}
+
+        <row>
+            <columns small="12">
+                <spacer size="10"></spacer>
+                    {% block content %}
+                    {% endblock %}
+                <spacer size="10"></spacer>
+            </columns>
+        </row>
+
+        {% block antispam %}
+            <row>
+                <columns small="12">
+                    <p>
+                        <small class="black">
+                            Cet e-mail a été envoyé automatiquement à
+                            #__#ANTISPAM_PERSON_EMAIL#__#
+                            par le logiciel Opentalent utilisé par votre structure.
+                            Merci de ne pas y répondre.
+                        </small>
+                    </p>
+                </columns>
+            </row>
+        {% endblock %}
+
+        {% block footer %}
+            <row>
+                <columns small="12" class="footer">
+                    <center>
+                        <menu>
+                            <small>
+                                <item href="https://support.opentalent.fr" class="white">Aide du logiciel</item>
+                                -
+                                <item href="https://www.opentalent.fr/login" class="white">Se connecter au logiciel</item>
+                            </small>
+                        </menu>
+                    </center>
+                </columns>
+            </row>
+        {% endblock %}
+
+        {% block footer_signature %}
+
+            <row>
+                <columns small="12">
+                    <spacer size="10"></spacer>
+                    <p class="text-center">
+                        <small class="black">
+                            &copy; Opentalent - {{ "now"|date("Y") }} - La plateforme culturelle : agenda culturel et logiciels pour les structures culturelles -
+                            <a href="https://www.opentalent.fr/agenda">www.opentalent.fr/agenda</a>
+                        </small>
+                    </p>
+                </columns>
+            </row>
+
+        {% endblock %}
+
+    </container>
+
+{% endapply %}

+ 38 - 0
templates/emails/report.html.twig

@@ -0,0 +1,38 @@
+{% if delivered is not empty  %}
+    <p>Le message a bien été envoyé aux {{ delivered|length }} contacts suivants : </p>
+
+    <ul>
+        {% for contact in delivered %}
+            <li>
+                {% if contact.name is not empty %}
+                    {{ contact.name }}
+                {% else %}
+                    {{ contact.emailAddress }}
+                {% endif %}
+            </li>
+        {% endfor %}
+    </ul>
+{% endif %}
+
+{% if unDelivered is not empty  %}
+    <p>Le message n'a pu être envoyé aux {{ unDelivered|length }} contacts suivants : </p>
+
+    <ul>
+        {% for contact in unDelivered %}
+            <li>
+                {% if contact.name is not empty %}
+                    {{ contact.name }}
+                {% else %}
+                    {{ contact.emailAddress }}
+                {% endif %}
+            </li>
+        {% endfor %}
+    </ul>
+{% endif %}
+
+<p>
+    Ci dessous un exemple du message que les destinataires ont reçu.<br />
+    Dans le cas d'un publipostage, un des destinataires est sélectionné pour l'exemple :
+</p>
+
+{{ email_example.text | raw }}

+ 27 - 13
templates/emails/subdomain.html.twig

@@ -1,17 +1,31 @@
-{% if  access.adminAccess %}
-Cher administrateur de {{access.organization.name}},
-{% else %}
-Cher {{access.person.givenName}} {{access.person.name}},
-{% endif %}
-votre demande d'activation du sous-domaine '{{ subdomain.subdomain }}' a été prise en compte et sera effective d'ici quelques minutes.
+{% extends 'emails/base.html.twig' %}
 
-Votre site sera dè lors accessible à l'adresse suivante :
+{% block content %}
 
-<a href="{{url}}">{{url}}</a>
+    <p>
+        {% if  access.adminAccess %}
+            Cher administrateur de {{access.organization.name}},
+        {% else %}
+            Cher {{access.person.givenName}} {{access.person.name}},
+        {% endif %}
+    </p>
+    <p>
+        Votre demande d'activation du sous-domaine '{{ subdomain.subdomain }}' a été prise en compte et sera effective d'ici quelques minutes.
+    </p>
 
-{% if  access.adminAccess %}
-Notez que votre identifiant est désormais : {{access.person.username}}
-Votre mot de passe reste inchangé.
-{% endif %}
+    <p>
+        Votre site sera dè lors accessible à l'adresse suivante :
+        <br/>
+        <a href="{{url}}">{{url}}</a>
+    </p>
 
-{% include '@templates/layout/noreply/footer.html.twig' with {'name': _to.name, 'email': _to.address } only %}
+
+    {% if  access.adminAccess %}
+        <p>
+            Notez que votre identifiant est désormais : {{access.person.username}}
+            <br />
+            Votre mot de passe reste inchangé.
+        </p>
+    {% endif %}
+
+{% endblock %}

+ 13 - 0
templates/emails/test.html.twig

@@ -0,0 +1,13 @@
+{% extends 'emails/base.html.twig' %}
+
+{% block content %}
+
+    <row>
+        <columns small="12">
+            <spacer size="100"></spacer>
+            text
+            <spacer size="100"></spacer>
+        </columns>
+    </row>
+
+{% endblock %}

+ 0 - 6
templates/layout/noreply/footer.html.twig

@@ -1,6 +0,0 @@
-Merci !<br>
-L'équipe Opentalent<br>
-<hr/>
-<em>Cet e-mail a été envoyé automatiquement. Merci de ne pas y répondre.<br>
-    Cet e-mail a été adressé à :<br>
-    {{name}} <{{email}}></em>

+ 0 - 124
tests/Service/MailHubTest.php

@@ -1,124 +0,0 @@
-<?php /** @noinspection PhpUnhandledExceptionInspection */
-
-namespace App\Tests\Service;
-
-use App\Entity\Access\Access;
-use App\Entity\Core\ContactPoint;
-use App\Entity\Organization\Organization;
-use App\Entity\Person\Person;
-use App\Service\Access\Utils as AccessUtils;
-use App\Service\Core\ContactPointUtils;
-use App\Service\MailHub;
-use PHPUnit\Framework\TestCase;
-use Symfony\Bridge\Twig\Mime\TemplatedEmail;
-use Symfony\Component\Mailer\MailerInterface;
-
-class MailHubTest extends TestCase
-{
-    private MailerInterface $mailer;
-    private string $opentalentNoReplyEmailAddress;
-    private ContactPointUtils $contactPointUtils;
-    private AccessUtils $accessUtils;
-
-    public function setUp(): void {
-        $this->mailer = $this->getMockBuilder(MailerInterface::class)->disableOriginalConstructor()->getMock();
-        $this->opentalentNoReplyEmailAddress = 'noreply@opentalent.fr';
-        $this->contactPointUtils = $this->getMockBuilder(ContactPointUtils::class)->disableOriginalConstructor()->getMock();
-        $this->accessUtils = $this->getMockBuilder(AccessUtils::class)->disableOriginalConstructor()->getMock();
-    }
-
-    /**
-     * @see MailHub::sendAutomaticEmailTo()
-     */
-    public function testSendAutomaticEmailTo(): void
-    {
-        $mailerHub = $this->getMockBuilder(MailHub::class)
-            ->setConstructorArgs([$this->mailer, $this->opentalentNoReplyEmailAddress, $this->contactPointUtils, $this->accessUtils])
-            ->setMethodsExcept(['sendAutomaticEmailTo'])
-            ->getMock();
-
-        $contactPoint = $this->getMockBuilder(ContactPoint::class)->disableOriginalConstructor()->getMock();
-        $contactPoint->method('getEmail')->willReturn('mail@domain.net');
-
-        $person = $this->getMockBuilder(Person::class)->disableOriginalConstructor()->getMock();
-        $person->method('getFullName')->willReturn('Don Diego de la Vega');
-        $person->method('getUsername')->willReturn('zorro2000');
-
-        $access = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
-        $access->method('getPerson')->willReturn($person);
-
-        $this->contactPointUtils->expects(self::once())->method('getPersonContactPointPrincipal')->willReturn($contactPoint);
-
-        $this->mailer
-            ->expects(self::once())
-            ->method('send')
-            ->with(self::isInstanceOf(TemplatedEmail::class));
-
-        $mailerHub->sendAutomaticEmailTo($access, 'subject', 'a_template', []);
-    }
-
-    /**
-     * @see MailHub::sendAutomaticEmailTo()
-     */
-    public function testSendAutomaticEmailToButNoAddress(): void
-    {
-        $mailerHub = $this->getMockBuilder(MailHub::class)
-            ->setConstructorArgs([$this->mailer, $this->opentalentNoReplyEmailAddress, $this->contactPointUtils, $this->accessUtils])
-            ->setMethodsExcept(['sendAutomaticEmailTo'])
-            ->getMock();
-
-        $access = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
-
-        $this->contactPointUtils->expects(self::once())->method('getPersonContactPointPrincipal')->willReturn(null);
-
-        $this->expectException(\RuntimeException::class);
-
-        $mailerHub->sendAutomaticEmailTo($access, 'subject', 'a_template', []);
-    }
-
-    /**
-     * @see MailHub::sendAutomaticEmailToAdmin()
-     */
-    public function testSendAutomaticEmailToAdmin(): void
-    {
-        $mailerHub = $this->getMockBuilder(MailHub::class)
-            ->setConstructorArgs([$this->mailer, $this->opentalentNoReplyEmailAddress, $this->contactPointUtils, $this->accessUtils])
-            ->setMethodsExcept(['sendAutomaticEmailToAdmin'])
-            ->getMock();
-
-        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
-        $admin = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
-
-        $this->accessUtils->expects(self::once())->method('findAdminFor')->with($organization)->willReturn($admin);
-
-        $mailerHub
-            ->expects(self::once())
-            ->method('sendAutomaticEmailTo')
-            ->with($admin, 'subject', 'template', []);
-
-        $mailerHub->sendAutomaticEmailToAdmin($organization, 'subject', 'template', []);
-    }
-
-    /**
-     * @see MailHub::sendAutomaticEmailToAdmin()
-     */
-    public function testSendAutomaticEmailToAdminButNoAdmin(): void
-    {
-        $mailerHub = $this->getMockBuilder(MailHub::class)
-            ->setConstructorArgs([$this->mailer, $this->opentalentNoReplyEmailAddress, $this->contactPointUtils, $this->accessUtils])
-            ->setMethodsExcept(['sendAutomaticEmailToAdmin'])
-            ->getMock();
-
-        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
-
-        $this->accessUtils
-            ->expects(self::once())
-            ->method('findAdminFor')
-            ->with($organization)
-            ->willReturn(null);
-
-        $this->expectException(\RuntimeException::class);
-
-        $mailerHub->sendAutomaticEmailToAdmin($organization, 'subject', 'template', []);
-    }
-}

+ 179 - 0
tests/Service/Mailer/Builder/AbstractBuilderTest.php

@@ -0,0 +1,179 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Tests\Service\Mailer\Builder;
+
+use App\Entity\Access\Access;
+use App\Entity\Core\ContactPoint;
+use App\Entity\Message\Email;
+use App\Entity\Organization\Organization;
+use App\Entity\Person\Person;
+use App\Enum\Core\ContactPointTypeEnum;
+use App\Enum\Core\EmailSendingTypeEnum;
+use App\Enum\Message\ReportMessageStatusEnum;
+use App\Repository\Core\ContactPointRepository;
+use App\Service\Mailer\Builder\AbstractBuilder;
+use App\Service\Mailer\EmailRecipient;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Twig\Environment;
+
+/**
+ * Classe AbstractBuilderTest qui test le service AbstractBuilder
+ */
+class AbstractBuilderTest extends TestCase
+{
+    private MockObject | ContactPointRepository $contactPointRepository;
+    private MockObject | Environment $twig;
+    private MockObject | Access $access;
+
+    public function setUp(): void {
+        $this->contactPointRepository = $this->getMockBuilder(ContactPointRepository::class)->disableOriginalConstructor()->getMock();
+        $this->twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock();
+        $this->access = $this->getMockBuilder(Access::class)->getMock();
+    }
+
+    /**
+     * AbstractBuilder mock maker
+     * @param string $methodUnderTest
+     * @return AbstractBuilder|MockObject
+     */
+    private function makeAbstractBuilderMock(string $methodUnderTest): AbstractBuilder | MockObject
+    {
+        $abstractBuilder = $this->getMockBuilder(AbstractBuilder::class)
+            ->setMethodsExcept(['setTwig', 'setContactPointRepository', $methodUnderTest])
+            ->getMock();
+
+        $abstractBuilder->setContactPointRepository($this->contactPointRepository);
+        $abstractBuilder->setTwig($this->twig);
+
+        return $abstractBuilder;
+    }
+
+    /**
+     * @see AbstractBuilder::buildEmailEntity()
+     */
+    public function testBuildEmailEntity(){
+        $abstractBuilder = $this->makeAbstractBuilderMock('buildEmailEntity');
+        $email = $abstractBuilder->buildEmailEntity('sujet', $this->access, 'contenu');
+
+        $this->assertInstanceOf(Email::class, $email);
+        $this->assertEquals('sujet', $email->getAbout());
+        $this->assertEquals('contenu', $email->getText());
+        $this->assertEquals($this->access, $email->getAuthor());
+        $this->assertTrue($email->getIsSystem());
+    }
+
+    /**
+     * @see AbstractBuilder::render()
+     */
+    public function testRender(){
+        $abstractBuilder = $this->makeAbstractBuilderMock('render');
+
+        $context = [];
+        $template = 'test';
+
+        $this->twig
+            ->expects(self::once())
+            ->method('render')
+            ->with(sprintf('@templates/emails/%s.html.twig', $template), $context);
+
+        $abstractBuilder->render($template, $context);
+    }
+
+    /**
+     * @see AbstractBuilder::addRecipient()
+     */
+    public function testAddRecipient(){
+        $abstractBuilder = $this->makeAbstractBuilderMock('addRecipient');
+        $sendType = EmailSendingTypeEnum::TO()->getValue();
+
+        //Access Target
+        $person = $this->getMockBuilder(Person::class)->getMock();
+        $person->method('getFullName')->willReturn('Marc Durand');
+        $this->access->method('getPerson')->willReturn($person);
+
+        $email = new \App\Service\Mailer\Email();
+        $abstractBuilder->addRecipient($email, $this->access, $sendType, ContactPointTypeEnum::PRINCIPAL()->getValue());
+
+        /** @var EmailRecipient $emailRecipient */
+        $emailRecipient = $email->getEmailRecipients()->first();
+        $this->assertEquals($sendType, $emailRecipient->getSendType());
+        $this->assertEquals($person->getFullName(), $emailRecipient->getName());
+        $this->assertEquals($this->access, $emailRecipient->getAccess());
+
+        //Organization Target
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getName')->willReturn('Test');
+
+        $email = new \App\Service\Mailer\Email();
+        $abstractBuilder->addRecipient($email, $organization, $sendType, ContactPointTypeEnum::PRINCIPAL()->getValue());
+
+        /** @var EmailRecipient $emailRecipient */
+        $emailRecipient = $email->getEmailRecipients()->first();
+        $this->assertEquals($organization, $emailRecipient->getOrganization());
+        $this->assertEquals($organization->getName(), $emailRecipient->getName());
+
+        //Custom Target
+        $custom = "foo.bar@opentalent.fr";
+        $email = new \App\Service\Mailer\Email();
+        $abstractBuilder->addRecipient($email, $custom, $sendType, ContactPointTypeEnum::PRINCIPAL()->getValue());
+
+        /** @var EmailRecipient $emailRecipient */
+        $emailRecipient = $email->getEmailRecipients()->first();
+        $this->assertEquals($custom, $emailRecipient->getEmailAddress());
+        $this->assertEquals(ReportMessageStatusEnum::DELIVERED()->getValue(), $emailRecipient->getSendStatus());
+    }
+
+    /**
+     * @see AbstractBuilder::setMailToRecipient()
+     */
+    public function testSetMailToRecipientWithoutEmail(){
+        $abstractBuilder = $this->makeAbstractBuilderMock('setMailToRecipient');
+        $contactPoint = $this->getMockBuilder(ContactPoint::class)->getMock();
+        $abstractBuilder->method('getFirstContactPointWithEmail')->willReturn(null);
+
+        $emailRecipient = new EmailRecipient();
+        $abstractBuilder->setMailToRecipient([$contactPoint], $emailRecipient);
+
+        $this->assertEquals(ReportMessageStatusEnum::MISSING()->getValue(), $emailRecipient->getSendStatus());
+    }
+
+    /**
+     * @see AbstractBuilder::setMailToRecipient()
+     */
+    public function testSetMailToRecipientWithEmail(){
+        $abstractBuilder = $this->makeAbstractBuilderMock('setMailToRecipient');
+        $contactPoint = $this->getMockBuilder(ContactPoint::class)->getMock();
+
+        $emailRecipient = new EmailRecipient();
+        $contactPoint->method('getEmail')->willReturn('foo.bar@opentalent.fr');
+        $abstractBuilder->method('getFirstContactPointWithEmail')->willReturn($contactPoint);
+        $abstractBuilder->setMailToRecipient([$contactPoint], $emailRecipient);
+
+        $this->assertEquals(ReportMessageStatusEnum::DELIVERED()->getValue(), $emailRecipient->getSendStatus());
+        $this->assertEquals('foo.bar@opentalent.fr', $emailRecipient->getEmailAddress());
+    }
+
+    /**
+     * @see AbstractBuilder::getFirstContactPointWithEmail()
+     */
+    public function testGetFirstContactPointWithEmail(){
+        $abstractBuilder = $this->makeAbstractBuilderMock('getFirstContactPointWithEmail');
+
+        $contactPoint = $this->getMockBuilder(ContactPoint::class)->getMock();
+        $contactPoint->method('getEmail')->willReturn('foo.bar@opentalent.fr');
+
+        $abstractBuilder->getFirstContactPointWithEmail([$contactPoint]);
+
+        $this->assertEquals('foo.bar@opentalent.fr', $contactPoint->getEmail());
+
+        $contactPoint2 = $this->getMockBuilder(ContactPoint::class)->getMock();
+        $contactPoint->method('getEmail')->willReturn(null);
+        $contactPoint2->method('getEmail')->willReturn('foo.bar@opentalent.fr');
+
+        $abstractBuilder->getFirstContactPointWithEmail([$contactPoint, $contactPoint2]);
+
+        $this->assertEquals('foo.bar@opentalent.fr', $contactPoint->getEmail());
+    }
+}

+ 118 - 0
tests/Service/Mailer/Builder/OnSubdomainChangeMailBuilderTest.php

@@ -0,0 +1,118 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Tests\Service\Mailer\Builder;
+
+use App\Entity\Access\Access;
+use App\Entity\Organization\Organization;
+use App\Entity\Organization\Subdomain;
+use App\Repository\Access\AccessRepository;
+use App\Repository\Organization\OrganizationRepository;
+use App\Repository\Organization\SubdomainRepository;
+use App\Service\Access\Utils as AccessUtils;
+use App\Service\Mailer\Builder\OnSubdomainChangeMailBuilder;
+use App\Service\Mailer\Email;
+use App\Service\Mailer\Mailer;
+use App\Service\Mailer\Model\SubdomainChangeModel;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Classe OnSubdomainChangeMailBuilderTest qui test le service SubDomainChangeBuilder
+ */
+class OnSubdomainChangeMailBuilderTest extends TestCase
+{
+    private MockObject|EntityManagerInterface $entityManager;
+    private string $opentalentNoReplyEmailAddress = 'no-reply@opentalent.fr';
+    private MockObject|AccessUtils $accessUtils;
+
+    public function setUp(): void
+    {
+        $this->entityManager = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->accessUtils = $this->getMockBuilder(AccessUtils::class)->disableOriginalConstructor()->getMock();
+    }
+
+    /**
+     * subDomainChangeBuilder mock maker
+     * @param string $methodUnderTest
+     * @return Mailer|MockObject
+     */
+    private function makeSubDomainChangeBuilderMock(string $methodUnderTest): OnSubdomainChangeMailBuilder | MockObject
+    {
+        return $this->getMockBuilder(OnSubdomainChangeMailBuilder::class)
+            ->setConstructorArgs([
+                $this->entityManager,
+                $this->opentalentNoReplyEmailAddress,
+                $this->accessUtils
+            ])
+            ->setMethodsExcept([$methodUnderTest])
+            ->getMock();
+    }
+
+    /**
+     * @see OnSubdomainChangeMailBuilder::build()
+     */
+    public function testBuild()
+    {
+        $subdomainId = 123;
+        $organizationId = 444;
+        $senderId = 333;
+
+        $subDomainChangeBuilder = $this->makeSubDomainChangeBuilderMock('build');
+
+        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $organization->method('getName')->willReturn('Mon organisation');
+        $access = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
+
+        $mailerModel = $this->getMockBuilder(SubdomainChangeModel::class)->disableOriginalConstructor()->getMock();
+        $mailerModel->method('getSubdomainId')->willReturn($subdomainId);
+        $mailerModel->method('getOrganizationId')->willReturn($organizationId);
+        $mailerModel->method('getSenderId')->willReturn($senderId);
+
+        $subdomainRepository = $this->getMockBuilder(SubdomainRepository::class)->disableOriginalConstructor()->getMock();
+        $subdomainRepository->expects(self::once())->method('find')->with($subdomainId)->willReturn($subdomain);
+        $organizationRepository = $this->getMockBuilder(OrganizationRepository::class)->disableOriginalConstructor()->getMock();
+        $organizationRepository->expects(self::once())->method('find')->with($organizationId)->willReturn($organization);
+        $accessRepository = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+        $accessRepository->expects(self::once())->method('find')->with($senderId)->willReturn($access);
+
+        $this->entityManager
+            ->expects(self::exactly(3))
+            ->method('getRepository')
+            ->withConsecutive([Subdomain::class], [Organization::class], [Access::class])
+            ->willReturnOnConsecutiveCalls($subdomainRepository, $organizationRepository, $accessRepository)
+            ;
+
+        $this->accessUtils
+            ->expects(self::once())
+            ->method('findAdminFor')
+            ->with($organization)
+            ->willReturn($access)
+        ;
+
+        $context = [
+            'access' => $access,
+            'organization' => $organization,
+            'subdomain' => $subdomain,
+            'url' => $mailerModel->getUrl()
+        ];
+        $content = 'contenu';
+        $subDomainChangeBuilder
+            ->expects(self::once())
+            ->method('render')
+            ->with('subdomain', $context)
+            ->willReturn($content)
+        ;
+
+        $emailsCollection = $subDomainChangeBuilder->build($mailerModel);
+
+        $this->assertCount(1, $emailsCollection);
+        $email = $emailsCollection->first();
+        $this->assertInstanceOf(Email::class, $email);
+        $this->assertEquals('contenu', $email->getContent());
+        $this->assertEquals($this->opentalentNoReplyEmailAddress, $email->getFrom());
+        $this->assertEquals($organization->getName(), $email->geFromName());
+    }
+}

+ 433 - 0
tests/Service/Mailer/MailerTest.php

@@ -0,0 +1,433 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Tests\Service\Mailer;
+
+use App\Entity\Access\Access;
+use App\Entity\Message\ReportEmail;
+use App\Entity\Person\Person;
+use App\Enum\Core\EmailSendingTypeEnum;
+use App\Enum\Message\MessageStatusEnum;
+use App\Enum\Message\ReportMessageStatusEnum;
+use App\Service\Mailer\Builder\BuilderInterface;
+use App\Service\Mailer\Email;
+use App\Service\Mailer\EmailRecipient;
+use App\Service\Mailer\Mailer;
+use App\Service\Mailer\Model\MailerModelInterface;
+use App\Service\ServiceIterator\Mailer\BuilderIterator;
+use App\Service\Utils\Environnement;
+use App\Service\Utils\StringsUtils;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use Ramsey\Uuid\Uuid;
+use Symfony\Bridge\Twig\Mime\TemplatedEmail;
+use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\Mime\Address;
+use Symfony\Component\Mime\Email as SymfonyEmail;
+
+/**
+ * Classe MailerTest qui test le service Mailer
+ */
+class MailerTest extends TestCase
+{
+    private string $opentalentNoReplyEmailAddress;
+    private MockObject | MailerInterface $symfonyMailer;
+    private MockObject | BuilderIterator $builderIterator;
+    private MockObject | StringsUtils $stringsUtils;
+    private MockObject | EntityManagerInterface $entityManager;
+    private MockObject | Environnement $environnement;
+    private MockObject | LoggerInterface $logger;
+
+    public function setUp(): void {
+        $this->symfonyMailer = $this->getMockBuilder(MailerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->opentalentNoReplyEmailAddress = 'noreply@opentalent.fr';
+        $this->builderIterator = $this->getMockBuilder(BuilderIterator::class)->disableOriginalConstructor()->getMock();
+        $this->stringsUtils = $this->getMockBuilder(StringsUtils::class)->disableOriginalConstructor()->getMock();
+        $this->entityManager = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->environnement = $this->getMockBuilder(Environnement::class)->disableOriginalConstructor()->getMock();
+        $this->logger = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock();
+    }
+
+    /**
+     * mailer mock maker
+     * @param string $methodUnderTest
+     * @return Mailer|MockObject
+     */
+    private function makeMailerMock(string $methodUnderTest): Mailer | MockObject
+    {
+        $mailer = $this->getMockBuilder(Mailer::class)
+            ->setConstructorArgs([
+                $this->symfonyMailer,
+                $this->opentalentNoReplyEmailAddress,
+                $this->builderIterator,
+                $this->stringsUtils,
+                $this->entityManager,
+                $this->environnement,
+                $this->logger,
+            ])
+            ->setMethodsExcept([$methodUnderTest])
+            ->getMock();
+
+        return $mailer;
+    }
+
+    private function makeEmailEntity(): \App\Entity\Message\Email | MockObject{
+        $person = $this->getMockBuilder(Person::class)->disableOriginalConstructor()->getMock();
+        $person->method('getFullName')->willReturn('Ben Yolo');
+
+        $author = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
+        $author->method('getPerson')->willReturn($person);
+
+        $emailEntity = $this->getMockBuilder(\App\Entity\Message\Email::class)->disableOriginalConstructor()->getMock();
+        $emailEntity->method('getAuthor')->willReturn($author);
+        $emailEntity->method('getAbout')->willReturn('sujet');
+        $emailEntity->method('getUuid')->willReturn(Uuid::fromString('177ef0d8-6630-11ea-b69a-0242ac130003'));
+
+        return $emailEntity;
+    }
+
+    /**
+     * @see Mailer::main()
+     */
+    public function testMain(){
+        $mailer = $this->makeMailerMock('main');
+        $mailerModel = $this->getMockBuilder(MailerModelInterface::class)->disableOriginalConstructor()->getMock();
+        $builderService = $this->getMockBuilder(BuilderInterface::class)->disableOriginalConstructor()->getMock();
+
+        $email = $this->getMockBuilder(Email::class)->disableOriginalConstructor()->getMock();
+        $email->method('getEmailRecipients')->willReturn(new ArrayCollection());
+        $email->method('getEmailEntity')->willReturn(new \App\Entity\Message\Email());
+
+        $emailsCollection = new ArrayCollection([$email]);
+
+        $this->builderIterator
+            ->expects(self::once())
+            ->method('getBuilderFor')
+            ->willReturn($builderService);
+
+        $builderService
+            ->expects(self::once())
+            ->method('build')
+            ->with($mailerModel)
+            ->willReturn($emailsCollection)
+        ;
+
+        $mailer
+            ->expects(self::once())
+            ->method('reduceEmailsCollectionInPreproduction')
+            ->with($emailsCollection)
+            ->willReturn($emailsCollection)
+        ;
+
+        $mailer
+            ->expects(self::once())
+            ->method('sendReport')
+            ->with($emailsCollection)
+            ;
+
+        $this->entityManager
+            ->expects(self::once())
+            ->method('flush')
+        ;
+
+        $emailsCollection = $mailer->main($mailerModel);
+
+        $this->assertEquals(MessageStatusEnum::NO_RECIPIENT()->getValue(), $email->getEmailEntity()->getStatus());
+
+        $this->assertInstanceOf(Email::class, $emailsCollection->first());
+    }
+
+    /**
+     * @see Mailer::send()
+     */
+    public function testSend(){
+        $mailer = $this->makeMailerMock('send');
+
+        $email = $this->getMockBuilder(Email::class)->disableOriginalConstructor()->getMock();
+        $email->method('getEmailEntity')->willReturn(new \App\Entity\Message\Email());
+
+        $symfonyEmail = $this->getMockBuilder(SymfonyEmail::class)->disableOriginalConstructor()->getMock();
+        $symfonyEmail->method('getTo')->willReturn([]);
+
+        $mailer
+            ->expects(self::once())
+            ->method('createSymfonyEmail')
+            ->with($email)
+            ->willReturn($symfonyEmail)
+        ;
+
+        $mailer
+            ->expects(self::once())
+            ->method('addRecipients')
+            ->with($symfonyEmail, $email)
+        ;
+
+        $mailer
+            ->expects(self::once())
+            ->method('setAntiSpam')
+            ->with($email, $symfonyEmail->getTo())
+        ;
+
+        $mailer
+            ->expects(self::once())
+            ->method('setSymfonyEmailContent')
+            ->with($symfonyEmail, $email)
+        ;
+
+        $mailer
+            ->expects(self::once())
+            ->method('addHeaders')
+            ->with($symfonyEmail, $email)
+        ;
+
+        $this->symfonyMailer
+            ->expects(self::once())
+            ->method('send')
+            ->with($symfonyEmail)
+        ;
+
+        $mailer->send($email);
+
+        $this->assertEquals(MessageStatusEnum::SEND()->getValue(), $email->getEmailEntity()->getStatus());
+    }
+
+    /**
+     * @see Mailer::sendReport()
+     */
+    public function testSendReport(){
+        $mailer = $this->makeMailerMock('sendReport');
+
+        $email = $this->getMockBuilder(Email::class)->disableOriginalConstructor()->getMock();
+        $templatedEmail = $this->getMockBuilder(TemplatedEmail::class)->disableOriginalConstructor()->getMock();
+
+        $mailer->method('createReportEmail')->willReturn($templatedEmail);
+        $this->symfonyMailer->expects(self::once())->method('send')->with($templatedEmail);
+
+        $mailer->sendReport(new ArrayCollection([$email]));
+    }
+
+    /**
+     * @see Mailer::createReportEmail()
+     */
+    public function testCreateReportEmail(){
+        $mailer = $this->makeMailerMock('createReportEmail');
+        $mailer->method('getDeliveredAndUndelivered')->willReturn([[], []]);
+
+        $emailEntity = $this->makeEmailEntity();
+
+        $email = $this->getMockBuilder(Email::class)->disableOriginalConstructor()->getMock();
+        $email->method('getEmailEntity')->willReturn($emailEntity);
+        $email->method('getFrom')->willReturn('foo.bar@opentalent.fr');
+        $email->method('geFromName')->willReturn('Bill');
+
+        $reportEmail = $mailer->createReportEmail(new ArrayCollection([$email]));
+
+        $this->assertInstanceOf(TemplatedEmail::class, $reportEmail);
+    }
+
+    /**
+     * @see Mailer::getDeliveredAndUndelivered()
+     */
+    public function testGetDeliveredAndUndelivered(){
+        $mailer = $this->makeMailerMock('getDeliveredAndUndelivered');
+
+        $emailRecipient = $this->getMockBuilder(EmailRecipient::class)->disableOriginalConstructor()->getMock();
+        $emailRecipient->method('getSendStatus')->willReturn(ReportMessageStatusEnum::MISSING()->getValue());
+        $emailRecipient2 = $this->getMockBuilder(EmailRecipient::class)->disableOriginalConstructor()->getMock();
+        $emailRecipient2->method('getSendStatus')->willReturn(ReportMessageStatusEnum::DELIVERED()->getValue());
+        $email = $this->getMockBuilder(Email::class)->disableOriginalConstructor()->getMock();
+        $email->method('getEmailRecipients')->willReturn(new ArrayCollection([$emailRecipient, $emailRecipient2]));
+
+        $emailRecipient3 = $this->getMockBuilder(EmailRecipient::class)->disableOriginalConstructor()->getMock();
+        $emailRecipient3->method('getSendStatus')->willReturn(ReportMessageStatusEnum::DELIVERED()->getValue());
+        $emailRecipient4 = $this->getMockBuilder(EmailRecipient::class)->disableOriginalConstructor()->getMock();
+        $emailRecipient4->method('getSendStatus')->willReturn(ReportMessageStatusEnum::MISSING()->getValue());
+        $email2 = $this->getMockBuilder(Email::class)->disableOriginalConstructor()->getMock();
+        $email2->method('getEmailRecipients')->willReturn(new ArrayCollection([$emailRecipient3, $emailRecipient4]));
+
+        [$delivered, $undelivered] = $mailer->getDeliveredAndUndelivered(new ArrayCollection([$email, $email2]));
+
+        $this->assertCount(2, $delivered);
+        $this->assertCount(2, $undelivered);
+    }
+
+    /**
+     * @see Mailer::persistEmailEntity()
+     */
+    public function testPersistEmailEntity(){
+        $mailer = $this->makeMailerMock('persistEmailEntity');
+
+        $emailRecipient = $this->getMockBuilder(EmailRecipient::class)->disableOriginalConstructor()->getMock();
+        $emailRecipient2 = $this->getMockBuilder(EmailRecipient::class)->disableOriginalConstructor()->getMock();
+
+        $email = $this->getMockBuilder(Email::class)->disableOriginalConstructor()->getMock();
+        $emailEntity = $this->makeEmailEntity();
+
+        $email->method('getEmailEntity')->willReturn($emailEntity);
+        $email->method('getEmailRecipients')->willReturnOnConsecutiveCalls(new ArrayCollection([$emailRecipient, $emailRecipient2]));
+
+        $emailEntity->expects(self::exactly(2))->method('addReport');
+        $this->entityManager->expects(self::once())->method('persist')->with($emailEntity);
+
+        $mailer->persistEmailEntity($email);
+    }
+
+    /**
+     * @see Mailer::createReport()
+     */
+    public function testCreateReport(){
+        $mailer = $this->makeMailerMock('createReport');
+
+        $access = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
+
+        $emailRecipient = $this->getMockBuilder(EmailRecipient::class)->disableOriginalConstructor()->getMock();
+        $emailRecipient->method('getEmailAddress')->willReturn('foo.bar@opentalent.fr');
+        $emailRecipient->method('getName')->willReturn('Will Farel');
+        $emailRecipient->method('getSendType')->willReturn(EmailSendingTypeEnum::TO()->getValue());
+        $emailRecipient->method('getAccess')->willReturn($access);
+        $emailRecipient->method('getSendStatus')->willReturn(ReportMessageStatusEnum::DELIVERED()->getValue());
+
+        $report = $mailer->createReport($emailRecipient);
+
+        $this->assertInstanceOf(ReportEmail::class, $report);
+        $this->assertEquals('foo.bar@opentalent.fr', $report->getAddressEmail());
+        $this->assertEquals($access, $report->getAccess());
+        $this->assertEquals(ReportMessageStatusEnum::DELIVERED()->getValue(), $report->getStatus());
+    }
+
+    /**
+     * @see Mailer::reduceEmailsCollectionInPreproduction()
+     */
+    public function testReduceEmailsCollectionInPreproduction(){
+        $mailer = $this->makeMailerMock('reduceEmailsCollectionInPreproduction');
+        $environnement = $this->getMockBuilder(Environnement::class)->disableOriginalConstructor()->getMock();
+        $environnement->method('get')->willReturn('dev');
+
+        $array = [];
+        for($i=0; $i<=50; $i++){
+            $array[] = $i;
+        }
+
+        $arrayReduced = $mailer->reduceEmailsCollectionInPreproduction(new ArrayCollection($array));
+
+        $this->assertCount(20, $arrayReduced->toArray());
+
+        $array = [];
+        for($i=0; $i<=9; $i++){
+            $array[] = $i;
+        }
+        $arrayReduced = $mailer->reduceEmailsCollectionInPreproduction(new ArrayCollection($array));
+        $this->assertCount(10, $arrayReduced->toArray());
+    }
+
+    /**
+     * @see Mailer::addHeaders()
+     */
+    public function testAddHeaders(){
+        $mailer = $this->makeMailerMock('addHeaders');
+
+        $emailEntity = $this->makeEmailEntity();
+
+        $email = $this->getMockBuilder(Email::class)->disableOriginalConstructor()->getMock();
+        $email->method('getFrom')->willReturn('foo.bar@opentalent.fr');
+        $email->method('getEmailEntity')->willReturn($emailEntity);
+
+        $symfonyMail = new SymfonyEmail();
+
+        $mailer->addHeaders($symfonyMail, $email);
+
+        $this->assertEquals('mailto:foo.bar@opentalent.fr?subject=désabonnement', $symfonyMail->getHeaders()->get('List-Unsubscribe')->getBody());
+        $this->assertEquals('177ef0d8-6630-11ea-b69a-0242ac130003', $symfonyMail->getHeaders()->get('X-ID-OT')->getBody());
+    }
+
+    /**
+     * @see Mailer::setAntiSpam()
+     */
+    public function testSetAntiSpam(){
+        $mailer = $this->makeMailerMock('setAntiSpam');
+
+        $email = new Email();
+        $email->setContent('#__#ANTISPAM_PERSON_EMAIL#__#');
+
+        $addressMailFrom = new Address('foo.bar@opentalent.fr', 'Bob dit lane');
+
+        $mailer->setAntiSpam( $email, [$addressMailFrom]);
+
+        $this->assertEquals('foo.bar@opentalent.fr', $email->getContent());
+    }
+
+    /**
+     * @see Mailer::createSymfonyEmail()
+     */
+    public function testCreateSymfonyEmail(){
+        $mailer = $this->makeMailerMock('createSymfonyEmail');
+
+        $emailEntity = $this->makeEmailEntity();
+
+        $email = $this->getMockBuilder(Email::class)->disableOriginalConstructor()->getMock();
+        $email->method('getFrom')->willReturn('foo.bar@opentalent.fr');
+        $email->method('geFromName')->willReturn('Ben Yolo');
+        $email->method('getEmailEntity')->willReturn($emailEntity);
+        $email->method('getContent')->willReturn('contenu');
+
+        $addressMailFrom = new Address($email->getFrom(), $email->geFromName());
+        $symfonyMail = $mailer->createSymfonyEmail($email);
+        $this->assertInstanceOf(SymfonyEmail::class, $symfonyMail);
+        $this->assertEquals($symfonyMail->getFrom(), [$addressMailFrom]);
+        $this->assertEquals($symfonyMail->getReplyTo(), [$addressMailFrom]);
+        $this->assertEquals($symfonyMail->getReturnPath(), Address::create("mail.report@opentalent.fr"));
+        $this->assertEquals($symfonyMail->getSubject(), $emailEntity->getAbout());
+    }
+
+    /**
+     * @see Mailer::setSymfonyEmailContent()
+     */
+    public function testSetSymfonyEmailContent(){
+        $mailer = $this->makeMailerMock('setSymfonyEmailContent');
+
+        $email = $this->getMockBuilder(Email::class)->disableOriginalConstructor()->getMock();
+        $email->method('getContent')->willReturn('contenu');
+
+        $this->stringsUtils
+            ->expects(self::once())
+            ->method('convertHtmlToText')
+            ->willReturn($email->getContent());
+
+        $symfonyMail = new SymfonyEmail();
+
+        $mailer->setSymfonyEmailContent($symfonyMail, $email);
+
+        $this->assertEquals($symfonyMail->getHtmlBody(), $email->getContent());
+        $this->assertEquals($symfonyMail->getTextBody(), $email->getContent());
+    }
+
+    /**
+     * @see Mailer::addRecipients()
+     */
+    public function testAddRecipients(){
+        $mailer = $this->makeMailerMock('addRecipients');
+        $symfonyMail = new SymfonyEmail();
+
+        $emailRecipient = $this->getMockBuilder(EmailRecipient::class)->disableOriginalConstructor()->getMock();
+        $emailRecipient->method('getEmailAddress')->willReturn('foo.bar@opentalent.fr');
+        $emailRecipient->method('getName')->willReturn('Will Farel');
+        $emailRecipient->method('getSendType')->willReturn(EmailSendingTypeEnum::TO()->getValue());
+
+        $emailRecipientBBC = $this->getMockBuilder(EmailRecipient::class)->disableOriginalConstructor()->getMock();
+        $emailRecipientBBC->method('getEmailAddress')->willReturn('foo.bar@opentalent.fr.bbc');
+        $emailRecipientBBC->method('getName')->willReturn('Will Farel');
+        $emailRecipientBBC->method('getSendType')->willReturn(EmailSendingTypeEnum::BBC()->getValue());
+
+        $email = $this->getMockBuilder(Email::class)->disableOriginalConstructor()->getMock();
+        $email->method('getEmailRecipients')->willReturn(new ArrayCollection([$emailRecipient, $emailRecipientBBC]));
+
+        $mailer->addRecipients($symfonyMail, $email);
+
+        $this->assertCount(1, $symfonyMail->getTo());
+        $this->assertCount(1, $symfonyMail->getBcc());
+        $this->assertCount(0, $symfonyMail->getCc());
+    }
+}

+ 45 - 26
tests/Service/OnChange/Organization/OnSubdomainChangeTest.php

@@ -5,9 +5,9 @@ namespace App\Tests\Service\OnChange\Organization;
 use App\Entity\Access\Access;
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Subdomain;
+use App\Message\Command\MailerCommand;
 use App\Message\Command\Typo3\Typo3UpdateCommand;
-use App\Service\Access\Utils as AccessUtils;
-use App\Service\MailHub;
+use App\Service\Mailer\Model\SubdomainChangeModel;
 use App\Service\OnChange\OnChangeContext;
 use App\Service\OnChange\Organization\OnSubdomainChange;
 use App\Service\Organization\Utils as OrganizationUtils;
@@ -17,27 +17,26 @@ use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\Messenger\Envelope;
 use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Security\Core\Security;
 
 class OnSubdomainChangeTest extends TestCase
 {
     private OrganizationUtils $organizationUtils;
-    private AccessUtils $accessUtils;
-    private MailHub $mailHub;
+    private Security $security;
     private BindFileService $bindFileService;
     private MessageBusInterface $messageBus;
 
     public function setUp():void
     {
         $this->organizationUtils = $this->getMockBuilder(OrganizationUtils::class)->disableOriginalConstructor()->getMock();
-        $this->accessUtils = $this->getMockBuilder(AccessUtils::class)->disableOriginalConstructor()->getMock();
-        $this->mailHub = $this->getMockBuilder(MailHub::class)->disableOriginalConstructor()->getMock();
+        $this->security = $this->getMockBuilder(Security::class)->disableOriginalConstructor()->getMock();
         $this->bindFileService = $this->getMockBuilder(BindFileService::class)->disableOriginalConstructor()->getMock();
         $this->messageBus = $this->getMockBuilder(MessageBusInterface::class)->disableOriginalConstructor()->getMock();
     }
 
     private function makeOnSubdomainChangeMock(string $methodName): MockObject | OnSubdomainChange {
         return $this->getMockBuilder(OnSubdomainChange::class)
-            ->setConstructorArgs([$this->organizationUtils, $this->accessUtils, $this->mailHub, $this->bindFileService, $this->messageBus])
+            ->setConstructorArgs([$this->organizationUtils, $this->bindFileService, $this->messageBus, $this->security])
             ->setMethodsExcept([$methodName])
             ->getMock();
     }
@@ -221,15 +220,32 @@ class OnSubdomainChangeTest extends TestCase
     public function testSendEmailAfterSubdomainChange(): void {
         $onSubdomainChange = $this->makeOnSubdomainChangeMock('sendEmailAfterSubdomainChange');
 
-        $admin = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
+        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
+        $subdomainChangeModel = $this->getMockBuilder(SubdomainChangeModel::class)->disableOriginalConstructor()->getMock();
 
-        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $onSubdomainChange
+            ->expects(self::once())
+            ->method('getMailModel')
+            ->with($subdomain)
+            ->willReturn($subdomainChangeModel);
 
-        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
-        $subdomain->expects(self::once())->method('getSubdomain')->willReturn('mysubdomain');
-        $subdomain->expects(self::exactly(3))->method('getOrganization')->willReturn($organization);
+        $this->messageBus
+            ->expects(self::once())
+            ->method('dispatch')
+            ->with(self::isInstanceOf(MailerCommand::class))
+            ->willReturn(new Envelope(new MailerCommand($subdomainChangeModel)));
+
+        $onSubdomainChange->sendEmailAfterSubdomainChange($subdomain);
+    }
 
-        $this->accessUtils->expects(self::once())->method('findAdminFor')->with($organization)->willReturn($admin);
+    /**
+     * @see OnSubdomainChange::getMailModel()
+     */
+    public function testGetMailModel(): void {
+        $onSubdomainChange = $this->makeOnSubdomainChangeMock('getMailModel');
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $organization->expects(self::once())->method('getId')->willReturn(1);
 
         $this->organizationUtils
             ->expects(self::once())
@@ -237,20 +253,23 @@ class OnSubdomainChangeTest extends TestCase
             ->with($organization)
             ->willReturn('mysubdomain.opentalent.fr');
 
-        $this->mailHub
+        $access = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
+        $access->expects(self::once())->method('getId')->willReturn(1);
+        $this->security
             ->expects(self::once())
-            ->method('sendAutomaticEmailToAdmin')
-            ->with(
-                $organization,
-                'Nouveau sous domaine: mysubdomain',
-                'subdomain',
-                [
-                    'access' => $admin,
-                    'subdomain' => $subdomain,
-                    'url' => 'mysubdomain.opentalent.fr'
-                ]
-            );
+            ->method('getUser')
+            ->willReturn($access);
 
-        $onSubdomainChange->sendEmailAfterSubdomainChange($subdomain);
+        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
+        $subdomain->expects(self::exactly(2))->method('getOrganization')->willReturn($organization);
+        $subdomain->expects(self::once())->method('getId')->willReturn(1);
+
+        $mailerModel = $onSubdomainChange->getMailModel($subdomain);
+
+        $this->assertInstanceOf(SubdomainChangeModel::class, $mailerModel);
+        $this->assertEquals($mailerModel->getSenderId(), 1);
+        $this->assertEquals($mailerModel->getOrganizationId(), 1);
+        $this->assertEquals($mailerModel->getSubdomainId(), 1);
+        $this->assertEquals($mailerModel->getUrl(), 'mysubdomain.opentalent.fr');
     }
 }

+ 7 - 0
tests/Service/Utils/StringsUtilsTest.php

@@ -22,4 +22,11 @@ class StringsUtilsTest extends TestCase
         $this->assertEquals("foo-bar", StringsUtils::camelToSnake("FooBar", '-'));
         $this->assertEquals("foo_bar", StringsUtils::camelToSnake("fooBar"));
     }
+
+    /**
+     * @see StringsUtils::convertHtmlToText()
+     */
+    public function testConvertHtmlToText(): void {
+        $this->assertEquals("Test contenu", (new StringsUtils())->convertHtmlToText("<table><tr><td>Test</td></tr></table> <br /><p>contenu</p>"));
+    }
 }