feat: vérification DNS Esy-Mail et Esy-Mailer en temps réel par domaine

EsyMailService - 2 nouvelles méthodes de vérification DNS :

checkDnsEsyMail(domain) — config réception (Dovecot) :
- MX → doit pointer vers ESYMAIL_HOSTNAME (mail.esy-web.dev)
- SPF → doit contenir le hostname mail ou include:_spf
- DKIM → sélecteur dkim._domainkey.domain (TXT ou CNAME)
- DMARC → _dmarc.domain doit contenir v=DMARC1
- Retourne ok=true si les 4 checks passent

checkDnsEsyMailer(domain) — config envoi (AWS SES) :
- SES domaine vérifié (isDomainVerified = Success)
- SES DKIM activé et vérifié (getDkimStatus enabled+verified)
- SPF → doit contenir include:amazonses.com
- MAIL FROM → configuré et vérifié (getMailFromStatus = Success)
- Retourne ok=true si les 4 checks passent

Intégration :
- Les checks DNS sont exécutés seulement sur l'onglet NDD (pas sur les
  autres onglets pour éviter les appels réseau inutiles)
- Les résultats alimentent configDnsEsyMail et configDnsEsyMailer
  dans la sous-ligne de chaque domaine (OK vert / KO rouge)

Configuration :
- .env : ESYMAIL_HOSTNAME (vide par défaut)
- .env.local : ESYMAIL_HOSTNAME=mail.esy-web.dev
- ansible/vault.yml : esymail_hostname pour la prod

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-04 21:03:02 +02:00
parent 310439cca2
commit 7648946c2b
4 changed files with 124 additions and 5 deletions

1
.env
View File

@@ -119,6 +119,7 @@ DISCORD_WEBHOOK=
###> esymail ###
ESYMAIL_DATABASE_URL=
ESYMAIL_HOSTNAME=
###< esymail ###
###> ovh ###

View File

@@ -20,6 +20,7 @@ mailcow_api_key: DF0E7E-0FD059-16226F-8ECFF1-E558B3
docuseal_api: pgAU116mCFmeF7WQSezHqxtZW8V1fgo31u5d2FXoaKe
docuseal_webhooks_secret: CRM_COSLAY
discord_webhook: https://discord.com/api/webhooks/1419573620602044518/ikAdxWxsrrTqMTb5Gh_8ylcoJHlOnq7aJZvR5udoS_fCK56Jk3qpEnJHVKdD8fwuNJF3
esymail_hostname: mail.esy-web.dev
ovh_key: 34bc2c2eb416b67d
ovh_secret: 12239d273975b5ab53318907fb66d355
ovh_customer: 56c387eb9ca4b9a2de4d4d97fd3d7f22

View File

@@ -8,6 +8,7 @@ use App\Repository\CustomerRepository;
use App\Entity\Domain;
use App\Service\CloudflareService;
use App\Service\DnsCheckService;
use App\Service\EsyMailService;
use App\Service\MailerService;
use App\Service\MeilisearchService;
use App\Service\OvhService;
@@ -133,20 +134,27 @@ class ClientsController extends AbstractController
*
* @return array<int, array{esyMail: bool, emailCount: int, esyMailer: bool, configDnsEsyMail: bool, configDnsEsyMailer: bool}>
*/
private function buildDomainsInfo(array $domains, EntityManagerInterface $em): array
private function buildDomainsInfo(array $domains, EntityManagerInterface $em, EsyMailService $esyMailService, bool $checkDns = false): array
{
$emailRepo = $em->getRepository(\App\Entity\DomainEmail::class);
$info = [];
foreach ($domains as $domain) {
$emailCount = $emailRepo->count(['domain' => $domain]);
$configMail = false;
$configMailer = false;
if ($checkDns) {
$configMail = $esyMailService->checkDnsEsyMail($domain->getFqdn())['ok'];
$configMailer = $esyMailService->checkDnsEsyMailer($domain->getFqdn())['ok'];
}
$info[$domain->getId()] = [
'esyMail' => $emailCount > 0,
'emailCount' => $emailCount,
'esyMailer' => false,
'configDnsEsyMail' => false,
'configDnsEsyMailer' => false,
'configDnsEsyMail' => $configMail,
'configDnsEsyMailer' => $configMailer,
];
}
@@ -341,7 +349,7 @@ class ClientsController extends AbstractController
}
#[Route('/{id}', name: 'show')]
public function show(Customer $customer, Request $request, EntityManagerInterface $em, OvhService $ovhService, CloudflareService $cloudflareService, DnsCheckService $dnsCheckService): Response
public function show(Customer $customer, Request $request, EntityManagerInterface $em, OvhService $ovhService, CloudflareService $cloudflareService, DnsCheckService $dnsCheckService, EsyMailService $esyMailService): Response
{
$tab = $request->query->getString('tab', 'info');
@@ -365,7 +373,7 @@ class ClientsController extends AbstractController
$this->ensureDefaultContact($customer, $em);
$contacts = $em->getRepository(\App\Entity\CustomerContact::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$domains = $em->getRepository(\App\Entity\Domain::class)->findBy(['customer' => $customer]);
$domainsInfo = $this->buildDomainsInfo($domains, $em);
$domainsInfo = $this->buildDomainsInfo($domains, $em, $esyMailService, 'ndd' === $tab);
return $this->render('admin/clients/show.html.twig', [
'customer' => $customer,

View File

@@ -13,7 +13,10 @@ class EsyMailService
public function __construct(
private LoggerInterface $logger,
private DnsCheckService $dnsCheck,
private AwsSesService $awsSes,
#[Autowire(env: 'ESYMAIL_DATABASE_URL')] private string $databaseUrl = '',
#[Autowire(env: 'ESYMAIL_HOSTNAME')] private string $mailHostname = '',
) {
}
@@ -341,6 +344,112 @@ class EsyMailService
}
}
// ──── Vérification DNS ──────────────────────────────
/**
* Vérifie la config DNS Esy-Mail (réception) pour un domaine.
* MX → mail hostname, SPF includes, DKIM, DMARC.
*
* @return array{ok: bool, mx: bool, spf: bool, dkim: bool, dmarc: bool, details: array<string, string>}
*/
public function checkDnsEsyMail(string $domain): array
{
$result = ['ok' => false, 'mx' => false, 'spf' => false, 'dkim' => false, 'dmarc' => false, 'details' => []];
// MX → doit pointer vers le mail hostname (ex: mail.esy-web.dev)
$mxRecords = $this->dnsCheck->getMxRecords($domain);
foreach ($mxRecords as $mx) {
if ('' !== $this->mailHostname && str_contains($mx['target'], $this->mailHostname)) {
$result['mx'] = true;
break;
}
}
$result['details']['mx'] = implode(', ', array_map(fn ($mx) => $mx['target'], $mxRecords)) ?: 'Aucun';
// SPF → doit contenir le mail hostname ou l'IP
$spfOutput = $this->dnsCheck->dig($domain, 'TXT');
foreach (explode("\n", $spfOutput) as $line) {
if (preg_match('/IN\s+TXT\s+"(v=spf1[^"]+)"/', $line, $m)) {
$spf = str_replace('" "', '', $m[1]);
if (str_contains($spf, $this->mailHostname) || str_contains($spf, 'include:_spf')) {
$result['spf'] = true;
}
$result['details']['spf'] = $spf;
break;
}
}
// DKIM → check sélecteur dkim._domainkey
$dkimFqdn = 'dkim._domainkey.'.$domain;
$dkimTxt = $this->dnsCheck->getDkimTxtRecord($dkimFqdn);
$dkimCname = $this->dnsCheck->getCnameRecord($dkimFqdn);
$result['dkim'] = null !== $dkimTxt || null !== $dkimCname;
$result['details']['dkim'] = $dkimTxt ?? $dkimCname ?? 'Non trouve';
// DMARC → _dmarc.domain
$dmarcOutput = $this->dnsCheck->dig('_dmarc.'.$domain, 'TXT');
foreach (explode("\n", $dmarcOutput) as $line) {
if (preg_match('/IN\s+TXT\s+"(v=DMARC1[^"]+)"/', $line, $m)) {
$result['dmarc'] = true;
$result['details']['dmarc'] = str_replace('" "', '', $m[1]);
break;
}
}
$result['ok'] = $result['mx'] && $result['spf'] && $result['dkim'] && $result['dmarc'];
return $result;
}
/**
* Vérifie la config DNS Esy-Mailer (envoi AWS SES) pour un domaine.
* SES domaine vérifié, DKIM SES, SPF include:amazonses.com, MAIL FROM.
*
* @return array{ok: bool, ses_verified: bool, ses_dkim: bool, spf_ses: bool, mail_from: bool, details: array<string, string>}
*/
public function checkDnsEsyMailer(string $domain): array
{
$result = ['ok' => false, 'ses_verified' => false, 'ses_dkim' => false, 'spf_ses' => false, 'mail_from' => false, 'details' => []];
if (!$this->awsSes->isAvailable()) {
$result['details']['error'] = 'AWS SES non configure';
return $result;
}
// SES domaine vérifié
$verif = $this->awsSes->isDomainVerified($domain);
$result['ses_verified'] = 'Success' === $verif;
$result['details']['ses_verified'] = $verif ?? 'Non verifie';
// SES DKIM
$dkim = $this->awsSes->getDkimStatus($domain);
$result['ses_dkim'] = $dkim['enabled'] && $dkim['verified'];
$result['details']['ses_dkim'] = ($dkim['enabled'] ? 'Enabled' : 'Disabled').', '.($dkim['verified'] ? 'Verified' : 'Not verified');
// SPF → doit contenir include:amazonses.com
$spfOutput = $this->dnsCheck->dig($domain, 'TXT');
foreach (explode("\n", $spfOutput) as $line) {
if (preg_match('/IN\s+TXT\s+"(v=spf1[^"]+)"/', $line, $m)) {
$spf = str_replace('" "', '', $m[1]);
if (str_contains($spf, 'amazonses.com')) {
$result['spf_ses'] = true;
}
$result['details']['spf'] = $spf;
break;
}
}
// MAIL FROM
$mailFrom = $this->awsSes->getMailFromStatus($domain);
$result['mail_from'] = 'Success' === ($mailFrom['mail_from_status'] ?? '');
$result['details']['mail_from'] = ($mailFrom['mail_from_domain'] ?? 'Non configure').' ('.($mailFrom['mail_from_status'] ?? '?').')';
$result['ok'] = $result['ses_verified'] && $result['ses_dkim'] && $result['spf_ses'] && $result['mail_from'];
return $result;
}
// ──── Stats ───────────────────────────────────────
/**