✨ 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:
8
.env
8
.env
@@ -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 ###
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
281
assets/class/MainframeEmailEditor.js
Normal file
281
assets/class/MainframeEmailEditor.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
193
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
134
src/Controller/AmazonSnsWebhooks.php
Normal file
134
src/Controller/AmazonSnsWebhooks.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
86
src/Service/Mailer/AmazonSesClient.php
Normal file
86
src/Service/Mailer/AmazonSesClient.php
Normal 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])
|
||||
];
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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') }}">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user