Comptabilite (Super Admin) : - ComptabiliteController avec 7 exports CSV/JSON compatibles SAGE (journal ventes, grand livre, FEC, balance agee, reglements, commissions Stripe 1.5%+0.25E, couts services) - Export PDF via ComptaPdf (FPDF) avec bloc legal pre-rempli, tableau pagine, champ signature DocuSeal - Signature electronique DocuSeal + callback + envoi email signe avec template dedie (compta_export_signed.html.twig) - Rapport financier public (RapportFinancierPdf) : recettes par service, depenses (Stripe, infra, prestataires), bilan excedent/deficit - Codes comptables clients EC-XXXX (plus de 411xxx) Prestataires (Super Admin) : - Entite Prestataire (raisonSociale, siret, email, phone, adresse) - Entite FacturePrestataire (numFacture, montantHt, montantTtc, year, month, isPaid, PDF via Vich) - CRUD complet avec recherche SIRET via proxy API data.gouv.fr - Commande cron app:reminder:factures-prestataire (5 du mois) - Factures prestataires integrees dans export couts services - Sidebar Super Admin : entree Prestataires + Comptabilite Stats (/admin/stats) : - Cout prestataire dynamique depuis FacturePrestataire - Fusion Infra + Prestataire en "Cout de fonctionnement" - Commission Stripe corrigee (1.5% + 0.25E par transaction) Divers : - DocuSealService::sendComptaForSignature() + getApi() - Customer::generateCodeComptable() format EC-XXXX-XXXXX - Protection double prefixe EC- a la creation client - Bouton regenerer PDF cache quand advert state=accepted - Modals sans script inline (data-modal-open/close dans app.js) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
271 lines
9.9 KiB
PHP
271 lines
9.9 KiB
PHP
<?php
|
|
|
|
namespace App\Service;
|
|
|
|
use App\Entity\EmailTracking;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
use Symfony\Component\Mailer\Messenger\SendEmailMessage;
|
|
use Symfony\Component\Messenger\MessageBusInterface;
|
|
use Symfony\Component\Mime\Crypto\SMimeSigner;
|
|
use Symfony\Component\Mime\Email;
|
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
|
|
|
class MailerService
|
|
{
|
|
public function __construct(
|
|
private MessageBusInterface $bus,
|
|
#[Autowire('%kernel.project_dir%')] private string $projectDir,
|
|
#[Autowire(env: 'SMIME_PASSPHRASE')] private string $smimePassphrase,
|
|
#[Autowire('%admin_email%')] private string $adminEmail,
|
|
private UrlGeneratorInterface $urlGenerator,
|
|
private UnsubscribeManager $unsubscribeManager,
|
|
private EntityManagerInterface $em,
|
|
) {
|
|
}
|
|
|
|
public function getAdminEmail(): string
|
|
{
|
|
return $this->adminEmail;
|
|
}
|
|
|
|
public function getAdminFrom(): string
|
|
{
|
|
return 'Association E-Cosplay <'.$this->adminEmail.'>';
|
|
}
|
|
|
|
public function send(Email $email): void
|
|
{
|
|
$publicKeyPath = $this->projectDir.'/key.asc';
|
|
|
|
if (file_exists($publicKeyPath)) {
|
|
$email->attachFromPath($publicKeyPath, 'public_key.asc', 'application/pgp-keys');
|
|
}
|
|
|
|
$certificate = $this->projectDir.'/config/cert/smime/certificate.pem';
|
|
$privateKey = $this->projectDir.'/config/cert/smime/private-key.pem';
|
|
|
|
// @codeCoverageIgnoreStart
|
|
if (file_exists($certificate) && file_exists($privateKey)) {
|
|
$signer = new SMimeSigner($certificate, $privateKey, $this->smimePassphrase);
|
|
$email = $signer->sign($email);
|
|
}
|
|
// @codeCoverageIgnoreEnd
|
|
|
|
$this->bus->dispatch(new SendEmailMessage($email));
|
|
}
|
|
|
|
/**
|
|
* @param array<array{path: string, name?: string}>|null $attachments
|
|
*/
|
|
public function sendEmail(string $to, string $subject, string $content, ?string $from = null, ?string $replyTo = null, bool $withUnsubscribe = true, ?array $attachments = null, int $priority = 3): void
|
|
{
|
|
$from ??= $this->getAdminFrom();
|
|
$canUnsubscribe = $withUnsubscribe && !$this->isWhitelisted($to);
|
|
|
|
if ($canUnsubscribe && $this->unsubscribeManager->isUnsubscribed($to)) {
|
|
return;
|
|
}
|
|
|
|
$email = (new Email())
|
|
->from($from)
|
|
->to($to)
|
|
->subject($subject)
|
|
->html($content)
|
|
->priority($priority);
|
|
|
|
if ($replyTo) {
|
|
$email->replyTo($replyTo);
|
|
}
|
|
|
|
if ($attachments) {
|
|
$processedAttachments = [];
|
|
foreach ($attachments as $attachment) {
|
|
$name = $attachment['name'] ?? basename($attachment['path']);
|
|
$email->attachFromPath($attachment['path'], $name);
|
|
$processedAttachments[] = [
|
|
'path' => $attachment['path'],
|
|
'name' => $name,
|
|
];
|
|
}
|
|
$attachments = $processedAttachments;
|
|
}
|
|
|
|
$messageId = bin2hex(random_bytes(16));
|
|
$email->getHeaders()->addIdHeader('Message-ID', $messageId.'@e-cosplay.fr');
|
|
|
|
$trackingUrl = $this->urlGenerator->generate('app_email_track', [
|
|
'messageId' => $messageId,
|
|
], UrlGeneratorInterface::ABSOLUTE_URL);
|
|
|
|
$viewUrl = $this->urlGenerator->generate('app_email_view', [
|
|
'messageId' => $messageId,
|
|
], UrlGeneratorInterface::ABSOLUTE_URL);
|
|
|
|
$html = $email->getHtmlBody();
|
|
$html = str_replace('https://crm.e-cosplay.fr/logo.jpg', $trackingUrl, $html);
|
|
$html = str_replace('__VIEW_URL__', $viewUrl, $html);
|
|
|
|
$dnsReportUrl = $this->urlGenerator->generate('app_dns_report', [
|
|
'token' => $messageId,
|
|
], UrlGeneratorInterface::ABSOLUTE_URL);
|
|
$html = str_replace('__DNS_REPORT_URL__', $dnsReportUrl, $html);
|
|
|
|
// Injection du bloc liste des pieces jointes (hors .asc, .p7z, smime)
|
|
if ($attachments) {
|
|
$html = $this->injectAttachmentsList($html, $attachments);
|
|
}
|
|
|
|
$email->html($html);
|
|
|
|
$tracking = new EmailTracking($messageId, $to, $subject, $html, $attachments);
|
|
$this->em->persist($tracking);
|
|
$this->em->flush();
|
|
|
|
// Ajout automatique du fichier VCF (fiche contact E-Cosplay)
|
|
$vcfPath = $this->generateVcf();
|
|
if (null !== $vcfPath) {
|
|
$email->attachFromPath($vcfPath, 'Association-E-Cosplay.vcf', 'text/vcard');
|
|
}
|
|
|
|
if ($canUnsubscribe) {
|
|
$this->addUnsubscribeHeaders($email, $to);
|
|
}
|
|
|
|
$this->send($email);
|
|
|
|
// Nettoyage du fichier VCF temporaire
|
|
if (null !== $vcfPath) {
|
|
@unlink($vcfPath);
|
|
}
|
|
}
|
|
|
|
private function isWhitelisted(string $email): bool
|
|
{
|
|
return strtolower(trim($email)) === strtolower($this->adminEmail);
|
|
}
|
|
|
|
/**
|
|
* Injecte un bloc HTML listant les pieces jointes dans le corps du mail,
|
|
* juste avant le footer dark (#111827). Exclut .asc, .p7z et smime.
|
|
*
|
|
* @param array<array{path: string, name: string}> $attachments
|
|
*/
|
|
private function injectAttachmentsList(string $html, array $attachments): string
|
|
{
|
|
$excluded = ['.asc', '.p7z'];
|
|
$filtered = [];
|
|
|
|
foreach ($attachments as $a) {
|
|
$name = $a['name'] ?? basename($a['path']);
|
|
$path = $a['path'] ?? '';
|
|
$ext = strtolower(pathinfo($name, \PATHINFO_EXTENSION));
|
|
if (\in_array('.'.$ext, $excluded, true) || str_contains(strtolower($name), 'smime')) {
|
|
continue;
|
|
}
|
|
$size = file_exists($path) ? filesize($path) : 0;
|
|
$filtered[] = ['name' => $name, 'size' => $size];
|
|
}
|
|
|
|
if ([] === $filtered) {
|
|
return $html;
|
|
}
|
|
|
|
$items = '';
|
|
foreach ($filtered as $f) {
|
|
$sizeStr = $this->formatFileSize($f['size']);
|
|
$items .= '<tr>'
|
|
.'<td style="background-color: #f9fafb; border: 1px solid #e5e5e5; padding-top: 12px; padding-bottom: 12px; padding-left: 16px; padding-right: 16px;">'
|
|
.'<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tbody><tr>'
|
|
.'<td style="padding-right: 12px; vertical-align: middle;"><span style="font-size: 24px;">📎</span></td>'
|
|
.'<td style="vertical-align: middle;">'
|
|
.'<p style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; font-weight: 700; margin-top: 0; margin-right: 0; margin-bottom: 2px; margin-left: 0;">'.htmlspecialchars($f['name'], \ENT_QUOTES, 'UTF-8').'</p>'
|
|
.'<p style="font-family: Arial, Helvetica, sans-serif; font-size: 11px; color: #888888; margin-top: 0; margin-right: 0; margin-bottom: 0; margin-left: 0;">Piece jointe ('.$sizeStr.')</p>'
|
|
.'</td>'
|
|
.'</tr></tbody></table>'
|
|
.'</td>'
|
|
.'</tr>';
|
|
}
|
|
|
|
$block = '<tr><td style="padding-top: 0; padding-bottom: 24px; padding-left: 32px; padding-right: 32px;">'
|
|
.'<p style="font-family: Arial, Helvetica, sans-serif; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; margin-top: 0; margin-bottom: 8px;">Pieces jointes</p>'
|
|
.'<table role="presentation" width="100%" cellpadding="0" cellspacing="4" border="0">'
|
|
.$items
|
|
.'</table>'
|
|
.'</td></tr>';
|
|
|
|
// Injecte avant le footer dark
|
|
$marker = '<td align="center" style="background-color: #111827';
|
|
$pos = strpos($html, $marker);
|
|
if (false !== $pos) {
|
|
$trPos = strrpos(substr($html, 0, $pos), '<tr>');
|
|
if (false !== $trPos) {
|
|
return substr($html, 0, $trPos).$block.substr($html, $trPos);
|
|
}
|
|
}
|
|
|
|
return $html;
|
|
}
|
|
|
|
/**
|
|
* Genere un fichier VCF (vCard 3.0) pour la fiche contact Association E-Cosplay.
|
|
*/
|
|
private function generateVcf(): ?string
|
|
{
|
|
$vcf = implode("\r\n", [
|
|
'BEGIN:VCARD',
|
|
'VERSION:3.0',
|
|
'N:E-Cosplay;Association;;;',
|
|
'FN:Association E-Cosplay',
|
|
'ORG:Association E-Cosplay',
|
|
'TEL;TYPE=WORK,VOICE:+33679348802',
|
|
'EMAIL;TYPE=INTERNET,PREF:contact@e-cosplay.fr',
|
|
'EMAIL;TYPE=INTERNET:contact@e-cosplay.fr',
|
|
'ADR;TYPE=WORK:;;42 rue de Saint-Quentin;Beautor;;02800;France',
|
|
'URL:https://www.e-cosplay.fr',
|
|
'URL:https://crm.e-cosplay.fr',
|
|
'NOTE:SIREN 943121517 - SIRET 943 121 517 00011 - APE 9329Z',
|
|
'CATEGORIES:Association,Cosplay,Evenementiel',
|
|
'REV:'.date('Ymd\THis\Z'),
|
|
'END:VCARD',
|
|
]);
|
|
|
|
$tmpPath = tempnam(sys_get_temp_dir(), 'vcf_');
|
|
if (false === $tmpPath) {
|
|
return null;
|
|
}
|
|
|
|
file_put_contents($tmpPath, $vcf);
|
|
|
|
return $tmpPath;
|
|
}
|
|
|
|
private function formatFileSize(int $bytes): string
|
|
{
|
|
if ($bytes >= 1048576) {
|
|
return number_format($bytes / 1048576, 1, ',', ' ').' Mo';
|
|
}
|
|
if ($bytes >= 1024) {
|
|
return number_format($bytes / 1024, 0, ',', ' ').' Ko';
|
|
}
|
|
|
|
return $bytes.' o';
|
|
}
|
|
|
|
private function addUnsubscribeHeaders(Email $email, string $to): void
|
|
{
|
|
$token = $this->unsubscribeManager->generateToken($to);
|
|
|
|
$unsubscribeUrl = $this->urlGenerator->generate('app_unsubscribe', [
|
|
'email' => $to,
|
|
'token' => $token,
|
|
], UrlGeneratorInterface::ABSOLUTE_URL);
|
|
|
|
$email->getHeaders()->addTextHeader(
|
|
'List-Unsubscribe',
|
|
sprintf('<%s>, <mailto:unsubscribe@e-cosplay.fr?subject=unsubscribe-%s>', $unsubscribeUrl, urlencode($to))
|
|
);
|
|
$email->getHeaders()->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click');
|
|
}
|
|
}
|