feat: service EsyMailService complet pour gestion messagerie
EsyMailService - connexion DBAL directe vers base esymail : Gestion domaines : - listDomains() : liste avec count mailboxes par domaine - getDomain(name) : détails d'un domaine - createDomain(name, maxMailboxes, defaultQuotaMb) : création - updateDomain(name, maxMailboxes, defaultQuotaMb, isActive) : mise à jour - deleteDomain(name) : suppression cascade (mailboxes + alias) - domainExists(name) : vérification existence Gestion boîtes mail : - listMailboxes(?domain) : liste toutes ou par domaine - getMailbox(email) : détails d'une boîte - createMailbox(email, password, ?displayName, quotaMb) : création avec hash bcrypt BLF-CRYPT, vérification domaine existant - updateMailbox(email, displayName, quotaMb, isActive) : mise à jour - changePassword(email, newPassword) : changement mot de passe - deleteMailbox(email) : suppression - mailboxExists(email) : vérification existence - countMailboxes(domain) : nombre de boîtes par domaine Gestion alias : - listAliases(?domain) : liste tous ou par domaine - createAlias(source, destination, domain) : création redirection - deleteAlias(id) : suppression Stats : - getStats() : compteurs domains, mailboxes, aliases, active_mailboxes Base de données esymail : - Table domain : name unique, max_mailboxes, default_quota_mb, is_active - Table mailbox : email unique, password bcrypt, domain FK, display_name, quota_mb, is_active, timestamps - Table alias : source/destination unique, domain FK, is_active - Domaines dev : siteconseil.fr, esy-web.dev - Compte test : test@siteconseil.fr / test1234 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
4
.env
4
.env
@@ -116,3 +116,7 @@ DOCUSEAL_WEBHOOKS_SECRET=
|
||||
###> discord ###
|
||||
DISCORD_WEBHOOK=
|
||||
###< discord ###
|
||||
|
||||
###> esymail ###
|
||||
ESYMAIL_DATABASE_URL=
|
||||
###< esymail ###
|
||||
|
||||
@@ -4,11 +4,26 @@ WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'esymail')\gexec
|
||||
|
||||
\connect esymail
|
||||
|
||||
-- Table des domaines
|
||||
CREATE TABLE IF NOT EXISTS domain (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
max_mailboxes INTEGER NOT NULL DEFAULT 50,
|
||||
default_quota_mb INTEGER NOT NULL DEFAULT 5120,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_domain_name ON domain (name);
|
||||
CREATE INDEX IF NOT EXISTS idx_domain_active ON domain (is_active);
|
||||
|
||||
-- Table des boites mail
|
||||
CREATE TABLE IF NOT EXISTS mailbox (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
domain VARCHAR(255) NOT NULL REFERENCES domain(name) ON DELETE CASCADE,
|
||||
display_name VARCHAR(255) DEFAULT NULL,
|
||||
quota_mb INTEGER NOT NULL DEFAULT 5120,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
@@ -19,11 +34,29 @@ CREATE INDEX IF NOT EXISTS idx_mailbox_email ON mailbox (email);
|
||||
CREATE INDEX IF NOT EXISTS idx_mailbox_domain ON mailbox (domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_mailbox_active ON mailbox (is_active);
|
||||
|
||||
-- Table des alias
|
||||
CREATE TABLE IF NOT EXISTS alias (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source VARCHAR(255) NOT NULL,
|
||||
destination VARCHAR(255) NOT NULL,
|
||||
domain VARCHAR(255) NOT NULL REFERENCES domain(name) ON DELETE CASCADE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(source, destination)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_alias_source ON alias (source);
|
||||
CREATE INDEX IF NOT EXISTS idx_alias_domain ON alias (domain);
|
||||
|
||||
-- Domaine de test dev
|
||||
INSERT INTO domain (name) VALUES ('siteconseil.fr') ON CONFLICT (name) DO NOTHING;
|
||||
INSERT INTO domain (name) VALUES ('esy-web.dev') ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Boite de test dev
|
||||
INSERT INTO mailbox (email, password, domain)
|
||||
INSERT INTO mailbox (email, password, domain, display_name)
|
||||
VALUES (
|
||||
'test@siteconseil.fr',
|
||||
-- Password: test1234 (bcrypt via BLF-CRYPT)
|
||||
'$2y$12$LJ3m4yPnMDCE1xPKm5VwS.YNbKH7JQXZ8VmYD5PJT5dKzJDkPmyG',
|
||||
'siteconseil.fr'
|
||||
'siteconseil.fr',
|
||||
'Compte Test'
|
||||
) ON CONFLICT (email) DO NOTHING;
|
||||
|
||||
375
src/Service/EsyMailService.php
Normal file
375
src/Service/EsyMailService.php
Normal file
@@ -0,0 +1,375 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
class EsyMailService
|
||||
{
|
||||
private ?Connection $conn = null;
|
||||
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
#[Autowire(env: 'ESYMAIL_DATABASE_URL')] private string $databaseUrl = '',
|
||||
) {
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return '' !== $this->databaseUrl;
|
||||
}
|
||||
|
||||
// ──── Domaines ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @return list<array{id: int, name: string, max_mailboxes: int, default_quota_mb: int, is_active: bool, created_at: string, mailbox_count: int}>
|
||||
*/
|
||||
public function listDomains(): array
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->getConnection()->fetchAllAssociative(
|
||||
'SELECT d.*, (SELECT COUNT(*) FROM mailbox m WHERE m.domain = d.name) AS mailbox_count FROM domain d ORDER BY d.name'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getDomain(string $name): ?array
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = $this->getConnection()->fetchAssociative('SELECT * FROM domain WHERE name = ?', [$name]);
|
||||
|
||||
return false === $result ? null : $result;
|
||||
}
|
||||
|
||||
public function createDomain(string $name, int $maxMailboxes = 50, int $defaultQuotaMb = 5120): bool
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->getConnection()->insert('domain', [
|
||||
'name' => strtolower(trim($name)),
|
||||
'max_mailboxes' => $maxMailboxes,
|
||||
'default_quota_mb' => $defaultQuotaMb,
|
||||
'is_active' => true,
|
||||
'created_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:sP'),
|
||||
], ['is_active' => 'boolean']);
|
||||
|
||||
$this->logger->info('EsyMail: domaine cree: '.$name);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('EsyMail: erreur creation domaine '.$name.': '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function updateDomain(string $name, int $maxMailboxes, int $defaultQuotaMb, bool $isActive): bool
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->getConnection()->update('domain', [
|
||||
'max_mailboxes' => $maxMailboxes,
|
||||
'default_quota_mb' => $defaultQuotaMb,
|
||||
'is_active' => $isActive,
|
||||
], ['name' => $name], ['is_active' => 'boolean']);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('EsyMail: erreur update domaine '.$name.': '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteDomain(string $name): bool
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->getConnection()->delete('domain', ['name' => $name]);
|
||||
$this->logger->info('EsyMail: domaine supprime: '.$name);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('EsyMail: erreur suppression domaine '.$name.': '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function domainExists(string $name): bool
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $this->getConnection()->fetchOne('SELECT COUNT(*) FROM domain WHERE name = ?', [strtolower($name)]) > 0;
|
||||
}
|
||||
|
||||
// ──── Boîtes mail ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listMailboxes(?string $domain = null): array
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (null !== $domain) {
|
||||
return $this->getConnection()->fetchAllAssociative(
|
||||
'SELECT * FROM mailbox WHERE domain = ? ORDER BY email', [$domain]
|
||||
);
|
||||
}
|
||||
|
||||
return $this->getConnection()->fetchAllAssociative('SELECT * FROM mailbox ORDER BY domain, email');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getMailbox(string $email): ?array
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = $this->getConnection()->fetchAssociative('SELECT * FROM mailbox WHERE email = ?', [$email]);
|
||||
|
||||
return false === $result ? null : $result;
|
||||
}
|
||||
|
||||
public function createMailbox(string $email, string $password, ?string $displayName = null, int $quotaMb = 5120): bool
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$parts = explode('@', $email);
|
||||
if (2 !== \count($parts)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$domain = strtolower($parts[1]);
|
||||
|
||||
if (!$this->domainExists($domain)) {
|
||||
$this->logger->error('EsyMail: domaine '.$domain.' inexistant pour '.$email);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->getConnection()->insert('mailbox', [
|
||||
'email' => strtolower(trim($email)),
|
||||
'password' => password_hash($password, \PASSWORD_BCRYPT),
|
||||
'domain' => $domain,
|
||||
'display_name' => $displayName,
|
||||
'quota_mb' => $quotaMb,
|
||||
'is_active' => true,
|
||||
'created_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:sP'),
|
||||
], ['is_active' => 'boolean']);
|
||||
|
||||
$this->logger->info('EsyMail: boite creee: '.$email);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('EsyMail: erreur creation boite '.$email.': '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function updateMailbox(string $email, ?string $displayName, int $quotaMb, bool $isActive): bool
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->getConnection()->update('mailbox', [
|
||||
'display_name' => $displayName,
|
||||
'quota_mb' => $quotaMb,
|
||||
'is_active' => $isActive,
|
||||
'updated_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:sP'),
|
||||
], ['email' => $email], ['is_active' => 'boolean']);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('EsyMail: erreur update boite '.$email.': '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function changePassword(string $email, string $newPassword): bool
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->getConnection()->update('mailbox', [
|
||||
'password' => password_hash($newPassword, \PASSWORD_BCRYPT),
|
||||
'updated_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:sP'),
|
||||
], ['email' => $email]);
|
||||
|
||||
$this->logger->info('EsyMail: password modifie pour '.$email);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('EsyMail: erreur changement password '.$email.': '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteMailbox(string $email): bool
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->getConnection()->delete('mailbox', ['email' => $email]);
|
||||
$this->logger->info('EsyMail: boite supprimee: '.$email);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('EsyMail: erreur suppression boite '.$email.': '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function mailboxExists(string $email): bool
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $this->getConnection()->fetchOne('SELECT COUNT(*) FROM mailbox WHERE email = ?', [strtolower($email)]) > 0;
|
||||
}
|
||||
|
||||
public function countMailboxes(string $domain): int
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) $this->getConnection()->fetchOne('SELECT COUNT(*) FROM mailbox WHERE domain = ?', [$domain]);
|
||||
}
|
||||
|
||||
// ──── Alias ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listAliases(?string $domain = null): array
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (null !== $domain) {
|
||||
return $this->getConnection()->fetchAllAssociative(
|
||||
'SELECT * FROM alias WHERE domain = ? ORDER BY source', [$domain]
|
||||
);
|
||||
}
|
||||
|
||||
return $this->getConnection()->fetchAllAssociative('SELECT * FROM alias ORDER BY domain, source');
|
||||
}
|
||||
|
||||
public function createAlias(string $source, string $destination, string $domain): bool
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->getConnection()->insert('alias', [
|
||||
'source' => strtolower(trim($source)),
|
||||
'destination' => strtolower(trim($destination)),
|
||||
'domain' => strtolower(trim($domain)),
|
||||
'is_active' => true,
|
||||
'created_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:sP'),
|
||||
], ['is_active' => 'boolean']);
|
||||
|
||||
$this->logger->info('EsyMail: alias cree: '.$source.' -> '.$destination);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('EsyMail: erreur creation alias '.$source.': '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteAlias(int $id): bool
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->getConnection()->delete('alias', ['id' => $id]);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('EsyMail: erreur suppression alias #'.$id.': '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ──── Stats ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @return array{domains: int, mailboxes: int, aliases: int, active_mailboxes: int}
|
||||
*/
|
||||
public function getStats(): array
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return ['domains' => 0, 'mailboxes' => 0, 'aliases' => 0, 'active_mailboxes' => 0];
|
||||
}
|
||||
|
||||
$conn = $this->getConnection();
|
||||
|
||||
return [
|
||||
'domains' => (int) $conn->fetchOne('SELECT COUNT(*) FROM domain'),
|
||||
'mailboxes' => (int) $conn->fetchOne('SELECT COUNT(*) FROM mailbox'),
|
||||
'aliases' => (int) $conn->fetchOne('SELECT COUNT(*) FROM alias'),
|
||||
'active_mailboxes' => (int) $conn->fetchOne('SELECT COUNT(*) FROM mailbox WHERE is_active = true'),
|
||||
];
|
||||
}
|
||||
|
||||
// ──── Connexion ───────────────────────────────────
|
||||
|
||||
private function getConnection(): Connection
|
||||
{
|
||||
if (null === $this->conn) {
|
||||
$this->conn = DriverManager::getConnection(['url' => $this->databaseUrl]);
|
||||
}
|
||||
|
||||
return $this->conn;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user