feat(assets/class): Ajoute un éditeur de template d'email personnalisé.

🔥 feat(assets/class): Supprime l'ancien éditeur d'email react-email-editor.
 feat(src/Controller): Ajoute un webhook Amazon SNS pour le suivi des emails.
📦 feat: Ajoute la librairie symfony/amazon-mailer.
This commit is contained in:
Serreau Jovann
2025-08-04 14:40:55 +02:00
parent 5cf3da1488
commit d8ec096db5
15 changed files with 940 additions and 48 deletions

8
.env
View File

@@ -64,3 +64,11 @@ DOCUSIGN_KEY=52u82oCoiG79awGsuxLfJqhxYjg8mrJfAsJJHejRMFa
STANCER_PRIVATE_KEY=stest_Rv4Hz8ae2wQdjnBVCays7wPo
STANCER_PUBLIC_KEY=ptest_raV5vZ51Lnp2DfBtu5TVs5o0
STANCER_ENV=test
AMAZON_SES_PUBLIC=AKIAWTT2T22CTKQWCMNA
AMAZON_SES_SECRET=BD63dADmgFJJPnjlT9utRDlvcOh8pRH3eOZXsyhNL/F3
###> symfony/amazon-mailer ###
# MAILER_DSN=ses://ACCESS_KEY:SECRET_KEY@default?region=eu-west-1
# MAILER_DSN=ses+smtp://ACCESS_KEY:SECRET_KEY@default?region=eu-west-1
###< symfony/amazon-mailer ###

View File

@@ -15,8 +15,7 @@ function script() {
customElements.define('auto-customer',AutoCustomer,{extends:'button'})
customElements.define('repeat-line',RepeatLine,{extends:'div'})
customElements.define('order-ctrl',OrderCtrl,{extends:'div'})
preactCustomElement("email-builder",MainframeEmailEditor)
customElements.define("email-builder",MainframeEmailEditor)
}
function full() {

View File

@@ -36,8 +36,216 @@ input {
padding: 0.5rem;
}
}
#ee {
display: block;
.loader-item{
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,0.7);
backdrop-filter: blur(5px);
z-index: 500;
width: 100%;
height: 100%;
span {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 2rem;
}
}
.email-builder-modal{
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #ffffff;
z-index: 500;
.side-modal{
height: 100%;
position: absolute;
background: white;
width: 25vw;
top: 0;
right: 0;
border-left: 1px solid #cdcdcd;
.td {
font-weight: bold;
color: black;
width: 100%;
margin-left: auto;
margin-right: auto;
display: block;
margin-top: 1rem;
padding-left: 1.5rem;
border-top: 1px solid black;
}
.input-padding{
.display {
height: 150px;
position: relative;
color: black;
margin-top: 0.5rem;
border: 1px solid black;
width: 90%;
margin-left: auto;
margin-right: auto;
}
.center{
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-weight: bold;
}
.top {
position: absolute;
left: 50%;
top: 10px;
transform: translate(-50%,15px);
}
.left {
position: absolute;
left: 30%;
top: 50%;
transform: translateY(-50%);
}
.right {
position: absolute;
right: 30%;
top: 50%;
transform: translateY(-50%);
}
.bottom {
position: absolute;
left: 50%;
bottom: 10px;
transform: translate(-50%,-15px);
}
}
.vl {
padding: 0.5rem;
label {
color: black;
font-weight: bold;
padding-left: 1.5rem;
}
}
.input-background{
width: 90%;
margin-left: auto;
margin-right: auto;
display: block;
label {
font-weight: bold;
color: black;
}
input {
display: block;
width: 100%;
border: 1px solid black;
background: none;
}
}
.closed-btn{
width: 100%;
background: var(--color-blue-900);
color: white;
padding: 0.25rem;
text-align: center;
font-weight: bold;
}
}
.area-block{
width: 100%;
margin-bottom: 0.5rem;
.area-name{
margin-left:0.5rem;
display: block;
font-weight: bold;
}
.area-settings{
margin-right:0.5rem;
display: block;
font-weight: bold;
}
.area-block-header{
width: 100%;
background: #cdcdcd;
display: flex;
justify-content: space-between;
border: 1px solid black;
}
.area-block-body{
min-height: 30px;
width: 100%;
display: block;
border-left: 1px solid black;
border-bottom: 1px solid black;
border-right: 1px solid black;
ul {
display: block;
min-height: 50px;
padding-bottom: 1rem;
}
}
}
.email-content {
.module{
margin: 0.5rem;
color: black;
.module-item{
cursor: pointer;
border: 1px solid black;
text-align: center;
font-weight: bold;
display: block;
padding: 0.25rem;
margin-bottom: 0.5rem;
}
}
.content{
margin: 0.5rem;
color: black;
padding-bottom: 2rem;
}
.area{
margin: 0.5rem;
color: black;
.area-item{
cursor: pointer;
border: 1px solid black;
text-align: center;
font-weight: bold;
display: block;
padding: 0.25rem;
margin-bottom: 0.5rem;
}
}
}
.email-builder-toolbar{
display: flex;
width: 100%;
border-bottom: 1px solid #cdcdcd;
background: #cdcdcd;
h2 {
color: black;
font-weight: bold;
width: 25%;
font-size: 1.75rem;
margin: auto;
padding-left: 1rem;
}
.email-builder-actions{
display: flex;
width: 100%;
justify-content: end;
}
}
}

View File

@@ -0,0 +1,281 @@
import {disableBodyScroll, enableBodyScroll, clearAllBodyScrollLocks} from 'body-scroll-lock';
import sortable from "sortablejs/src/Sortable.js";
export class MainframeEmailEditor extends HTMLElement {
connectedCallback() {
let element = this;
let button = document.createElement('button');
button.innerText = "Modifier le template";
button.classList = "px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded transition font-semibold w-full"
button.addEventListener('click', (event) => {
disableBodyScroll(this)
event.preventDefault();
console.warn("Created modal editor")
console.warn("created topbar control")
console.warn("updated left color area")
console.warn("create center area")
console.warn("create left color")
let modal = document.createElement('div');
modal.classList.add("email-builder-modal");
modal.innerHTML = `
<div class="email-builder-toolbar">
<h2>EsyMail - Editor</h2>
<div class="email-builder-actions">
<a class="closed px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white transition font-semibold">
Fermer
</a>
</div>
</div>
<div class="email-content grid grid-cols-5 grid-rows-1 gap-4">
<ul class="area">
<li class="area-item 1col">
<span>100%</span>
</li>
<li class="area-item 5050col">
<span>50% / 50% Colonne</span>
</li>
<li class="area-item 505050col">
<span>50% / 50% / 50% Colonne</span>
</li>
</ul>
<ul class="content col-span-3"></ul>
<ul class="module col-start-5">
<li class="module-item text">
<span>Texte</span>
</li>
<li class="module-item image">
<span>Image</span>
</li>
<li class="module-item social">
<span>Réseaux Sociaux</span>
</li>
<li class="module-item lien">
<span>Lien</span>
</li>
</ul>
</div>
`;
let closed = modal.querySelector('.closed');
closed.addEventListener('click', (event) => {
event.preventDefault();
modal.remove();
enableBodyScroll(this)
})
let area = modal.querySelector('.area');
let module = modal.querySelector('.module');
let content = modal.querySelector('.content');
new sortable(area, {
sort: false,
group: {
name: 'area',
pull: 'clone',
put: false,
},
});
new sortable(content, {
group: {
name: 'area',
revertClone: true,
},
onSort: function (/**Event*/evt) {
let results = [];
evt.to.querySelectorAll('li').forEach((ele, pos) => {
if (ele.classList.contains('area-item')) {
let type = ele.classList[1];
let element = document.createElement('li');
element.classList = "area-block";
element.setAttribute('type', type);
let config = {}
let name = "";
if (type == "1col") {
name = "1 Colonne"
config = {
backgroundColor: '#ffffff',
paddingLeft: 0,
paddingRight: 0,
paddingTop: 0,
paddingBottom: 0,
}
} else if (type == "5050col") {
name = "2 Colonne"
config = {
backgroundColor_1: '#ffffff',
paddingLeft_1: 0,
paddingRight_1: 0,
paddingTop_1: 0,
paddingBottom_1: 0,
backgroundColor_2: '#ffffff',
paddingLeft_2: 0,
paddingRight_2: 0,
paddingTop_2: 0,
paddingBottom_2: 0,
}
}
if (type == "505050col") {
name = "3 Colonne"
config = {
backgroundColor_1: '#ffffff',
paddingLeft_1: 0,
paddingRight_1: 0,
paddingTop_1: 0,
paddingBottom_1: 0,
backgroundColor_2: '#ffffff',
paddingLeft_2: 0,
paddingRight_2: 0,
paddingTop_2: 0,
paddingBottom_2: 0,
backgroundColor_3: '#ffffff',
paddingLeft_3: 0,
paddingRight_3: 0,
paddingTop_3: 0,
paddingBottom_3: 0,
}
}
element.setAttribute('config', JSON.stringify(config));
element.innerHTML = `
<div class="area-block-header">
<div class="area-name">${name}</div>
<div class="area-settings">
<button class="edit">Modifier</button>
<button class="remove">X</button>
</div>
</div>
<div class="area-block-body">
</div>
`;
if (type == "1col") {
let ulCol = document.createElement("ul");
ulCol.style.background = config.backgroundColor;
element.querySelector('.area-block-body').appendChild(ulCol);
let edit = element.querySelector('.edit');
edit.addEventListener('click', (event) => {
event.preventDefault();
edit.addEventListener('updateArea', (event) => {
element.setAttribute('config', JSON.stringify(event.detail));
})
let sideModal = document.createElement('div');
sideModal.classList ="side-modal";
sideModal.innerHTML = `
<div class="closed-btn">Fermer</div>
<div class="input-background">
<label>Fond de zone</label>
<input type="color" class="areaColor" required value="${config.backgroundColor}">
</div>
<div class="input-padding">
<label class="td">Espacement</label>
<div class="display">
<div class="center">Module</div>
<div class="top">${config.paddingTop}px</div>
<div class="bottom">${config.paddingBottom}px</div>
<div class="left">${config.paddingLeft}px</div>
<div class="right">${config.paddingRight}px</div>
</div>
<div class="input grid grid-cols-2">
<div class="vl">
<label>Haut</label>
<input type="range" min="0" max="50" step="5" class="top_input" value="${config.paddingTop}">
</div>
<div class="vl">
<label>Bas</label>
<input type="range" min="0" max="50" step="5" class="bot_input" value="${config.paddingBottom}">
</div>
<div class="vl">
<label>Gauche</label>
<input type="range" min="0" max="50" step="5" class="left_input" value="${config.paddingLeft}">
</div>
<div class="vl">
<label>Droite</label>
<input type="range" min="0" max="50" step="5" class="right_input" value="${config.paddingRight}">
</div>
</div>
</div>
`;
sideModal.querySelector('.right_input').addEventListener('input',(event)=>{
sideModal.querySelector('.right').innerHTML = event.target.value+"px";
element.querySelector('ul').style.paddingRight = event.target.value+"px";
})
sideModal.querySelector('.left_input').addEventListener('input',(event)=>{
sideModal.querySelector('.left').innerHTML = event.target.value+"px";
element.querySelector('ul').style.paddingLeft = event.target.value+"px";
})
sideModal.querySelector('.bot_input').addEventListener('input',(event)=>{
sideModal.querySelector('.bottom').innerHTML = event.target.value+"px";
element.querySelector('ul').style.paddingBottom = event.target.value+"px";
})
sideModal.querySelector('.top_input').addEventListener('input',(event)=>{
sideModal.querySelector('.top').innerHTML = event.target.value+"px";
element.querySelector('ul').style.paddingTop = event.target.value+"px";
})
sideModal.querySelector('.areaColor').addEventListener('input',(event)=>{
element.querySelector('ul').style.background = event.target.value;
})
sideModal.querySelector('.closed-btn').addEventListener('click', (event) => {
event.preventDefault();
sideModal.remove();
edit.dispatchEvent(new CustomEvent('updateArea',{
detail: {
backgroundColor: sideModal.querySelector('.areaColor').value,
paddingLeft: sideModal.querySelector('.left_input').value,
paddingRight: sideModal.querySelector('.right_input').value,
paddingTop: sideModal.querySelector('.top_input').value,
paddingBottom: sideModal.querySelector('.bot_input').value,
}
}));
})
modal.appendChild(sideModal);
})
}
ele.replaceWith(element);
element.querySelector('.remove').addEventListener('click', (e) => {
e.preventDefault();
element.remove();
})
results.push({
pos: pos,
elt: element,
})
} else {
results.push({
pos: pos,
elt: ele
})
}
})
}
});
new sortable(module, {
sort: false,
group: {
name: 'module',
pull: 'clone',
put: false,
},
});
this.appendChild(modal);
})
this.appendChild(button);
}
}

View File

@@ -1,38 +0,0 @@
import EmailEditor, { EditorRef, EmailEditorProps } from 'react-email-editor';
import { useRef } from 'preact/hooks';
export function MainframeEmailEditor() {
const emailEditorRef = useRef(null);
const exportHtml = (e) => {
e.preventDefault();
const unlayer = emailEditorRef.current?.editor;
unlayer?.exportHtml((data) => {
const { design, html } = data;
let format = JSON.stringify({
design,
html
})
document.body.querySelector('#template_content').setAttribute('value',format);
document.body.querySelector('#template_content').value = format;
});
};
const onReady =(unlayer) => {
let html = document.body.querySelector('#template_content').getAttribute('value');
let data = JSON.parse(html);
unlayer.loadDesign(data.design);
};
return (
<div>
<div>
<button class="button" onClick={exportHtml}>Sauvegarde le html</button>
</div>
<EmailEditor locale="fr-FR" ref={emailEditorRef} onReady={onReady} />
</div>
);
}

BIN
bun.lockb

Binary file not shown.

View File

@@ -36,6 +36,7 @@
"setasign/fpdi": "^2.6",
"spatie/mjml-php": "^1.2",
"stancer/stancer": "*",
"symfony/amazon-mailer": "7.3.*",
"symfony/asset": "7.3.*",
"symfony/asset-mapper": "7.3.*",
"symfony/console": "7.3.*",
@@ -68,7 +69,9 @@
"twig/extra-bundle": "^3.21",
"twig/intl-extra": "^3.21",
"twig/twig": "^3.21",
"vich/uploader-bundle": "^2.7"
"vich/uploader-bundle": "^2.7",
"ext-dom": "*",
"ext-libxml": "*"
},
"config": {
"allow-plugins": {

193
composer.lock generated
View File

@@ -4,8 +4,133 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "5069feeb5ac1e553a0a6c92eff275abd",
"content-hash": "50775f2b94bdcc231e1a598c0bdff988",
"packages": [
{
"name": "async-aws/core",
"version": "1.26.0",
"source": {
"type": "git",
"url": "https://github.com/async-aws/core.git",
"reference": "58ab79116d990e7053b2e31162f47df4223148c5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/async-aws/core/zipball/58ab79116d990e7053b2e31162f47df4223148c5",
"reference": "58ab79116d990e7053b2e31162f47df4223148c5",
"shasum": ""
},
"require": {
"ext-hash": "*",
"ext-json": "*",
"ext-simplexml": "*",
"php": "^7.2.5 || ^8.0",
"psr/cache": "^1.0 || ^2.0 || ^3.0",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"symfony/deprecation-contracts": "^2.1 || ^3.0",
"symfony/http-client": "^4.4.16 || ^5.1.7 || ^6.0 || ^7.0",
"symfony/http-client-contracts": "^1.1.8 || ^2.0 || ^3.0",
"symfony/service-contracts": "^1.0 || ^2.0 || ^3.0"
},
"conflict": {
"async-aws/s3": "<1.1",
"symfony/http-client": "5.2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.26-dev"
}
},
"autoload": {
"psr-4": {
"AsyncAws\\Core\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Core package to integrate with AWS. This is a lightweight AWS SDK provider by AsyncAws.",
"keywords": [
"amazon",
"async-aws",
"aws",
"sdk",
"sts"
],
"support": {
"source": "https://github.com/async-aws/core/tree/1.26.0"
},
"funding": [
{
"url": "https://github.com/jderusse",
"type": "github"
},
{
"url": "https://github.com/nyholm",
"type": "github"
}
],
"time": "2025-05-12T09:35:01+00:00"
},
{
"name": "async-aws/ses",
"version": "1.12.0",
"source": {
"type": "git",
"url": "https://github.com/async-aws/ses.git",
"reference": "904ee7b5c07d865c20db4c06c3c0b97e7035673d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/async-aws/ses/zipball/904ee7b5c07d865c20db4c06c3c0b97e7035673d",
"reference": "904ee7b5c07d865c20db4c06c3c0b97e7035673d",
"shasum": ""
},
"require": {
"async-aws/core": "^1.9",
"ext-json": "*",
"php": "^7.2.5 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.12-dev"
}
},
"autoload": {
"psr-4": {
"AsyncAws\\Ses\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "SES client, part of the AWS SDK provided by AsyncAws.",
"keywords": [
"amazon",
"async-aws",
"aws",
"sdk",
"ses"
],
"support": {
"source": "https://github.com/async-aws/ses/tree/1.12.0"
},
"funding": [
{
"url": "https://github.com/jderusse",
"type": "github"
},
{
"url": "https://github.com/nyholm",
"type": "github"
}
],
"time": "2025-05-12T09:35:01+00:00"
},
{
"name": "aws/aws-crt-php",
"version": "v1.2.7",
@@ -7194,6 +7319,72 @@
},
"time": "2024-11-15T17:47:59+00:00"
},
{
"name": "symfony/amazon-mailer",
"version": "v7.3.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/amazon-mailer.git",
"reference": "7266d4285147c890f4f7f42dc875fe5a6df8006c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/amazon-mailer/zipball/7266d4285147c890f4f7f42dc875fe5a6df8006c",
"reference": "7266d4285147c890f4f7f42dc875fe5a6df8006c",
"shasum": ""
},
"require": {
"async-aws/ses": "^1.8",
"php": ">=8.2",
"symfony/mailer": "^7.2"
},
"require-dev": {
"symfony/http-client": "^6.4|^7.0"
},
"type": "symfony-mailer-bridge",
"autoload": {
"psr-4": {
"Symfony\\Component\\Mailer\\Bridge\\Amazon\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony Amazon Mailer Bridge",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/amazon-mailer/tree/v7.3.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-02-26T16:10:57+00:00"
},
{
"name": "symfony/asset",
"version": "v7.3.0",

View File

@@ -29,6 +29,7 @@
"@tailwindcss/vite": "^4.1.10",
"@usewaypoint/email-builder": "^0.0.8",
"autoprefixer": "^10.4.21",
"body-scroll-lock": "^4.0.0-beta.0",
"react-email-editor": "^1.7.11",
"sortablejs": "^1.15.6",
"tailwindcss": "^4.1.10"

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Controller;
use App\Repository\CustomerAdvertPaymentRepository;
use App\Service\Mailer\Mailer;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class AmazonSnsWebhooks extends AbstractController
{
#[Route(path: '/webhooks/amazonSns',name: 'app_webhooks_amazonSns',methods: ['POST'])]
public function amazonSnsWebhooks(LoggerInterface $logger,Request $request): Response
{
// 1. Récupérer le corps du message SNS
$payload = $request->getContent();
$data = json_decode($payload, true);
if (!$data || !isset($data['Signature'], $data['SigningCertURL'], $data['Type'])) {
return new Response('Invalid SNS message', 400);
}
// 2. Télécharger le certificat public SNS
$certUrl = $data['SigningCertURL'];
// Vérifier que le certificat provient bien d'Amazon AWS SNS
if (
strpos($certUrl, 'https://sns.') !== 0 ||
strpos($certUrl, '.amazonaws.com/') === false
) {
return new Response('Invalid certificate URL', 400);
}
$certificate = file_get_contents($certUrl);
if ($certificate === false) {
return new Response('Unable to download cert', 400);
}
// 3. Reconstituer la chaîne à signer
$stringToSign = $this->buildStringToSign($data);
// 4. Vérifier la signature via OpenSSL
$signature = base64_decode($data['Signature']);
$ok = openssl_verify(
$stringToSign,
$signature,
$certificate,
OPENSSL_ALGO_SHA1
);
if ($ok !== 1) {
return new Response('Signature validation failed', 400);
}
// GÉRER LA VALIDATION DE LA SOUSCRIPTION SNS ICI
if ($data['Type'] === 'SubscriptionConfirmation' && isset($data['SubscribeURL'])) {
// Faire un appel HTTP GET vers SubscribeURL
$client = new \GuzzleHttp\Client();
try {
$client->get($data['SubscribeURL']);
} catch (\Exception $e) {
return new Response('Subscription confirmation failed', 500);
}
}
// Si c'est une notification Amazon SES
if ($data['Type'] === 'Notification') {
// Le message SNS est doublement encodé : il faut le décoder
$snsMessage = json_decode($data['Message'], true);
// On vérifie le type de notification (Delivery, Bounce, Complaint, etc)
if (isset($snsMessage['notificationType'])) {
if ($snsMessage['notificationType'] === 'Delivery') {
// Le mail a bien été délivré
// Tu peux stocker cette info en base, logger, etc
// Exemple simple d'extraction de l'adresse email destinataire :
$recipient = $snsMessage['mail']['destination'][0] ?? null;
$mailMessageId = $snsMessage['mail']['messageId'] ?? null;
$logger->info(sprintf('[Delivery] Email delivered: SES messageId=%s, recipient=%s', $mailMessageId, $recipient));
return new Response('Delivery processed', 200);
} elseif ($snsMessage['notificationType'] === 'Bounce') {
// Ici, gestion d'un mail en échec
} elseif ($snsMessage['notificationType'] === 'Complaint') {
// Ici, gestion d'une plainte
}
}
}
return new Response('OK', 200);
}
private function buildStringToSign(array $data): string
{
$type = $data['Type'];
// La documentation SNS exige des champs précis selon le Type.
$signableKeys = [];
if ($type === 'Notification') {
$signableKeys = [
'Message',
'MessageId',
'Subject',
'Timestamp',
'TopicArn',
'Type',
];
} elseif (in_array($type, ['SubscriptionConfirmation', 'UnsubscribeConfirmation'])) {
$signableKeys = [
'Message',
'MessageId',
'SubscribeURL',
'Timestamp',
'Token',
'TopicArn',
'Type',
];
}
$stringToSign = '';
foreach ($signableKeys as $key) {
if (isset($data[$key])) {
$stringToSign .= $key . "\n" . $data[$key] . "\n";
}
}
return $stringToSign;
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Controller\Artemis\Newsletter;
use App\Entity\Newsletter\Template;
use App\Form\Artemis\Newsletter\TemplateType;
use App\Repository\Newsletter\TemplateRepository;
use App\Service\Mailer\AmazonSesClient;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -41,7 +42,7 @@ class TemplateController extends AbstractController
#[Route(path: '/artemis/newsletter/template/add',name: 'artemis_newsletter_template_add',methods: ['GET', 'POST'])]
#[Route(path: '/artemis/newsletter/template/{id}',name: 'artemis_newsletter_template_edit',methods: ['GET', 'POST'])]
public function templateEditor(?Template $template,EntityManagerInterface $entityManager,Request $request): Response
public function templateEditor(?Template $template,AmazonSesClient $amazonSesClient,EntityManagerInterface $entityManager,Request $request): Response
{
if(is_null($template)){
$template = new Template();
@@ -57,6 +58,8 @@ class TemplateController extends AbstractController
return $this->redirectToRoute('artemis_newsletter_template_edit',['id'=>$template->getId()]);
}
return $this->render('artemis/newsletter/template/editor.twig', [
'form' => $form->createView(),
'template' => $template,

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Service\Mailer;
use App\Entity\Mail;
use Doctrine\ORM\EntityManagerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpKernel\Profiler\Profiler;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mailer\Transport\Dsn;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mailer\Transport\Smtp\SmtpTransport;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\Header\IdentificationHeader;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;
class AmazonSesClient
{
private \Symfony\Component\Mailer\Mailer $mailer;
public function __construct(private UrlGeneratorInterface $urlGenerator,EventDispatcherInterface $eventDispatcher)
{
//init ses client
$transport = new EsmtpTransport("email-smtp.eu-west-3.amazonaws.com",587,false,$eventDispatcher);
$transport->setUsername($_ENV['AMAZON_SES_PUBLIC']);
$transport->setPassword($_ENV['AMAZON_SES_SECRET']);
$this->mailer = new \Symfony\Component\Mailer\Mailer($transport);
}
public function send(string $html,string $address,string $subject)
{
$email = new Email();
$email->from('no-reply@siteconseil.fr');
$email->to($address);
$email->subject($subject);
$messageId = $email->generateMessageId();
$header = $email->getHeaders();
$header->add(new IdentificationHeader("Message-Id",$messageId));
$datas = $this->generateTracking($email);
$dom = new \DOMDocument();
libxml_use_internal_errors(true);
$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$img = $dom->createElement('img');
$img->setAttribute('src', $datas['url']);
$img->setAttribute('width',0);
$img->setAttribute('height',0);
$body = $dom->getElementsByTagName('body')->item(0);
if ($body) {
// Ajouter <img> avant la fin de </body>
$body->appendChild($img);
}
$newHtml = $dom->saveHTML();
$email->html($newHtml);
$this->mailer->send($email);
}
private function generateTracking(Email $email)
{
$messageFormat = $email->getHeaders()->get('message-id')->getBody()[0];
$messageFormat = str_replace("@siteconseil.fr","",$messageFormat);
$mailData = new Mail();
$mailData->setDest($email->getTo()[0]->getAddress());
$mailData->setSubject($email->getSubject());
$mailData->setMessageId($messageFormat);
$mailData->setStatus("draft");
return [
'object' => $mailData,
'url'=> "https://mainframe.esy-web.dev".$this->urlGenerator->generate('app_tracking',['slug'=>$messageFormat])
];
}
}

View File

@@ -131,6 +131,15 @@
"config/packages/sentry.yaml"
]
},
"symfony/amazon-mailer": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.4",
"ref": "9648db3ecae5c8a6b1a5f74715d3907124348815"
}
},
"symfony/asset-mapper": {
"version": "7.3",
"recipe": {

View File

@@ -6,7 +6,6 @@
<title>Mainframe - {% block title %}{% endblock %}</title>
<meta name="robots" content="noindex">
{{ vite_asset('admin.js',[]) }}
<link rel="stylesheet" href="{{ asset('assets/icons/css/all.min.css') }}">

View File

@@ -26,7 +26,6 @@
}}) }}
{{ form_errors(form.name) }}
</div>
<email-builder></email-builder>
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded transition font-semibold w-full">
Sauvegarder
</button>
@@ -35,4 +34,13 @@
<email-builder id="{{ template.id }}"></email-builder>
//content management
// left zone
// central editor
// right module
{% endblock %}