From fe42f221a631add442f361333c9fb9938663b595 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Fri, 3 Apr 2026 23:17:01 +0200 Subject: [PATCH] feat: service EsyMailService complet pour gestion messagerie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env | 4 + docker/dovecot/init-esymail.sql | 41 +++- src/Service/EsyMailService.php | 375 ++++++++++++++++++++++++++++++++ 3 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 src/Service/EsyMailService.php diff --git a/.env b/.env index b6b8379..08da98f 100644 --- a/.env +++ b/.env @@ -116,3 +116,7 @@ DOCUSEAL_WEBHOOKS_SECRET= ###> discord ### DISCORD_WEBHOOK= ###< discord ### + +###> esymail ### +ESYMAIL_DATABASE_URL= +###< esymail ### diff --git a/docker/dovecot/init-esymail.sql b/docker/dovecot/init-esymail.sql index 8987495..b2c497a 100644 --- a/docker/dovecot/init-esymail.sql +++ b/docker/dovecot/init-esymail.sql @@ -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; diff --git a/src/Service/EsyMailService.php b/src/Service/EsyMailService.php new file mode 100644 index 0000000..9853c76 --- /dev/null +++ b/src/Service/EsyMailService.php @@ -0,0 +1,375 @@ +databaseUrl; + } + + // ──── Domaines ──────────────────────────────────── + + /** + * @return list + */ + 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|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> + */ + 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|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> + */ + 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; + } +}