feat(13-email-mailboxes): phase 13 complete — email DB foundation + SMTP mailbox CRUD

3 migrations (email_mailboxes, email_templates, email_logs), full CRUD
for SMTP mailboxes with encrypted passwords (IntegrationSecretCipher),
native SMTP connection test via stream_socket_client, sidebar navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 23:57:33 +01:00
parent 8b3fb3fd0b
commit 3223aac4d9
16 changed files with 1257 additions and 19 deletions

View File

@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use Throwable;
final class EmailMailboxController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly EmailMailboxRepository $repository
) {
}
public function index(Request $request): Response
{
$t = $this->translator;
$mailboxes = $this->repository->listAll();
$editMailbox = null;
$editId = (int) $request->input('edit', '0');
if ($editId > 0) {
$editMailbox = $this->repository->findById($editId);
}
$html = $this->template->render('settings/email-mailboxes', [
'title' => $t->get('settings.email_mailboxes.title'),
'activeMenu' => 'settings',
'activeSettings' => 'email-mailboxes',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'mailboxes' => $mailboxes,
'editMailbox' => $editMailbox,
'successMessage' => Flash::get('settings.email_mailboxes.success', ''),
'errorMessage' => Flash::get('settings.email_mailboxes.error', ''),
], 'layouts/app');
return Response::html($html);
}
public function save(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings.email_mailboxes.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/settings/email-mailboxes');
}
$name = trim((string) $request->input('name', ''));
$smtpHost = trim((string) $request->input('smtp_host', ''));
$smtpUsername = trim((string) $request->input('smtp_username', ''));
$senderEmail = trim((string) $request->input('sender_email', ''));
if ($name === '' || $smtpHost === '' || $smtpUsername === '' || $senderEmail === '') {
Flash::set('settings.email_mailboxes.error', 'Nazwa, serwer SMTP, uzytkownik i e-mail nadawcy sa wymagane');
return Response::redirect('/settings/email-mailboxes');
}
if (!filter_var($senderEmail, FILTER_VALIDATE_EMAIL)) {
Flash::set('settings.email_mailboxes.error', 'Nieprawidlowy adres e-mail nadawcy');
return Response::redirect('/settings/email-mailboxes');
}
$id = $request->input('id', '');
$password = (string) $request->input('smtp_password', '');
if (($id === '' || $id === '0') && $password === '') {
Flash::set('settings.email_mailboxes.error', 'Haslo SMTP jest wymagane dla nowej skrzynki');
return Response::redirect('/settings/email-mailboxes');
}
try {
$this->repository->save([
'id' => $id,
'name' => $name,
'smtp_host' => $smtpHost,
'smtp_port' => $request->input('smtp_port', '587'),
'smtp_encryption' => $request->input('smtp_encryption', 'tls'),
'smtp_username' => $smtpUsername,
'smtp_password' => $password,
'sender_email' => $senderEmail,
'sender_name' => $request->input('sender_name', ''),
'is_default' => $request->input('is_default', null),
'is_active' => $request->input('is_active', null),
]);
Flash::set('settings.email_mailboxes.success', 'Skrzynka pocztowa zostala zapisana');
} catch (Throwable) {
Flash::set('settings.email_mailboxes.error', 'Blad zapisu skrzynki pocztowej');
}
return Response::redirect('/settings/email-mailboxes');
}
public function delete(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings.email_mailboxes.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/settings/email-mailboxes');
}
$id = (int) $request->input('id', '0');
if ($id <= 0) {
Flash::set('settings.email_mailboxes.error', 'Nieprawidlowy identyfikator skrzynki');
return Response::redirect('/settings/email-mailboxes');
}
try {
$this->repository->delete($id);
Flash::set('settings.email_mailboxes.success', 'Skrzynka pocztowa zostala usunieta');
} catch (Throwable) {
Flash::set('settings.email_mailboxes.error', 'Blad usuwania skrzynki pocztowej');
}
return Response::redirect('/settings/email-mailboxes');
}
public function toggleStatus(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings.email_mailboxes.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/settings/email-mailboxes');
}
$id = (int) $request->input('id', '0');
if ($id <= 0) {
Flash::set('settings.email_mailboxes.error', 'Nieprawidlowy identyfikator skrzynki');
return Response::redirect('/settings/email-mailboxes');
}
try {
$this->repository->toggleStatus($id);
Flash::set('settings.email_mailboxes.success', 'Status skrzynki zostal zmieniony');
} catch (Throwable) {
Flash::set('settings.email_mailboxes.error', 'Blad zmiany statusu skrzynki');
}
return Response::redirect('/settings/email-mailboxes');
}
public function testConnection(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
return Response::json(['success' => false, 'message' => 'Nieprawidlowy token CSRF'], 403);
}
$host = trim((string) $request->input('smtp_host', ''));
$port = (int) $request->input('smtp_port', '587');
$encryption = (string) $request->input('smtp_encryption', 'tls');
$username = trim((string) $request->input('smtp_username', ''));
$password = (string) $request->input('smtp_password', '');
$existingId = (int) $request->input('id', '0');
if ($existingId > 0 && $password === '') {
$existing = $this->repository->findById($existingId);
if ($existing !== null) {
$password = (string) ($existing['smtp_password_decrypted'] ?? '');
}
}
if ($host === '' || $username === '') {
return Response::json(['success' => false, 'message' => 'Serwer SMTP i uzytkownik sa wymagane'], 400);
}
$prefix = $encryption === 'ssl' ? 'ssl://' : ($encryption === 'tls' ? 'tls://' : '');
$address = $prefix . $host . ':' . $port;
$context = stream_context_create([
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
],
]);
$errorMessage = '';
set_error_handler(function (int $errno, string $errstr) use (&$errorMessage): bool {
$errorMessage = $errstr;
return true;
});
try {
$socket = @stream_socket_client($address, $errno, $errstr, 5, STREAM_CLIENT_CONNECT, $context);
if ($socket === false) {
$detail = $errstr ?: $errorMessage ?: 'Nie udalo sie polaczyc';
return Response::json([
'success' => false,
'message' => "Blad polaczenia z {$host}:{$port}{$detail}",
]);
}
$greeting = $this->readSmtpResponse($socket);
if (!str_starts_with($greeting, '220')) {
fclose($socket);
return Response::json([
'success' => false,
'message' => "Serwer nie odpowiedzial poprawnie: {$greeting}",
]);
}
$this->sendSmtpCommand($socket, "EHLO orderpro\r\n");
$ehloResponse = $this->readSmtpResponse($socket);
if ($encryption === 'tls' && !str_starts_with($prefix, 'tls://')) {
if (str_contains($ehloResponse, 'STARTTLS')) {
$this->sendSmtpCommand($socket, "STARTTLS\r\n");
$starttlsResponse = $this->readSmtpResponse($socket);
if (!str_starts_with($starttlsResponse, '220')) {
fclose($socket);
return Response::json([
'success' => false,
'message' => "STARTTLS nie powiodlo sie: {$starttlsResponse}",
]);
}
stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
$this->sendSmtpCommand($socket, "EHLO orderpro\r\n");
$this->readSmtpResponse($socket);
}
}
$this->sendSmtpCommand($socket, "AUTH LOGIN\r\n");
$authResponse = $this->readSmtpResponse($socket);
if (!str_starts_with($authResponse, '334')) {
fclose($socket);
return Response::json([
'success' => false,
'message' => "Serwer nie obsluguje AUTH LOGIN: {$authResponse}",
]);
}
$this->sendSmtpCommand($socket, base64_encode($username) . "\r\n");
$userResponse = $this->readSmtpResponse($socket);
if (!str_starts_with($userResponse, '334')) {
fclose($socket);
return Response::json([
'success' => false,
'message' => "Blad uwierzytelniania (uzytkownik): {$userResponse}",
]);
}
$this->sendSmtpCommand($socket, base64_encode($password) . "\r\n");
$passResponse = $this->readSmtpResponse($socket);
if (!str_starts_with($passResponse, '235')) {
fclose($socket);
return Response::json([
'success' => false,
'message' => "Blad uwierzytelniania (haslo): {$passResponse}",
]);
}
$this->sendSmtpCommand($socket, "QUIT\r\n");
fclose($socket);
return Response::json([
'success' => true,
'message' => "Polaczenie z {$host}:{$port} powiodlo sie. Uwierzytelnianie OK.",
]);
} catch (Throwable $e) {
return Response::json([
'success' => false,
'message' => "Blad: {$e->getMessage()}",
]);
} finally {
restore_error_handler();
}
}
/**
* @param resource $socket
*/
private function sendSmtpCommand($socket, string $command): void
{
fwrite($socket, $command);
}
/**
* @param resource $socket
*/
private function readSmtpResponse($socket): string
{
$response = '';
stream_set_timeout($socket, 5);
while ($line = fgets($socket, 512)) {
$response .= $line;
if (isset($line[3]) && $line[3] === ' ') {
break;
}
}
return trim($response);
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
final class EmailMailboxRepository
{
public function __construct(
private readonly PDO $pdo,
private readonly IntegrationSecretCipher $cipher
) {
}
/**
* @return list<array<string, mixed>>
*/
public function listAll(): array
{
$statement = $this->pdo->prepare(
'SELECT id, name, smtp_host, smtp_port, smtp_encryption, smtp_username,
sender_email, sender_name, is_default, is_active, created_at, updated_at
FROM email_mailboxes
ORDER BY is_default DESC, created_at DESC'
);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return array<string, mixed>|null
*/
public function findById(int $id): ?array
{
$statement = $this->pdo->prepare('SELECT * FROM email_mailboxes WHERE id = :id LIMIT 1');
$statement->execute(['id' => $id]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
if (!is_array($row)) {
return null;
}
if (isset($row['smtp_password_encrypted']) && $row['smtp_password_encrypted'] !== '') {
$row['smtp_password_decrypted'] = $this->cipher->decrypt($row['smtp_password_encrypted']);
}
return $row;
}
/**
* @return list<array<string, mixed>>
*/
public function listActive(): array
{
$statement = $this->pdo->prepare(
'SELECT id, name, sender_email, sender_name, is_default
FROM email_mailboxes
WHERE is_active = 1
ORDER BY is_default DESC, name ASC'
);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @param array<string, mixed> $data
*/
public function save(array $data): void
{
$id = isset($data['id']) && $data['id'] !== '' ? (int) $data['id'] : null;
$encryption = in_array((string) ($data['smtp_encryption'] ?? ''), ['tls', 'ssl', 'none'], true)
? (string) $data['smtp_encryption']
: 'tls';
$isDefault = isset($data['is_default']) ? 1 : 0;
if ($isDefault === 1) {
$this->pdo->prepare('UPDATE email_mailboxes SET is_default = 0 WHERE is_default = 1')->execute();
}
$params = [
'name' => trim((string) ($data['name'] ?? '')),
'smtp_host' => trim((string) ($data['smtp_host'] ?? '')),
'smtp_port' => (int) ($data['smtp_port'] ?? 587),
'smtp_encryption' => $encryption,
'smtp_username' => trim((string) ($data['smtp_username'] ?? '')),
'sender_email' => trim((string) ($data['sender_email'] ?? '')),
'sender_name' => trim((string) ($data['sender_name'] ?? '')) ?: null,
'is_default' => $isDefault,
'is_active' => isset($data['is_active']) ? 1 : 0,
];
$password = (string) ($data['smtp_password'] ?? '');
if ($password !== '') {
$params['smtp_password_encrypted'] = $this->cipher->encrypt($password);
}
if ($id !== null) {
$setClauses = [
'name = :name',
'smtp_host = :smtp_host',
'smtp_port = :smtp_port',
'smtp_encryption = :smtp_encryption',
'smtp_username = :smtp_username',
'sender_email = :sender_email',
'sender_name = :sender_name',
'is_default = :is_default',
'is_active = :is_active',
];
if (isset($params['smtp_password_encrypted'])) {
$setClauses[] = 'smtp_password_encrypted = :smtp_password_encrypted';
}
$params['id'] = $id;
$statement = $this->pdo->prepare(
'UPDATE email_mailboxes SET ' . implode(', ', $setClauses) . ' WHERE id = :id'
);
} else {
if (!isset($params['smtp_password_encrypted'])) {
$params['smtp_password_encrypted'] = '';
}
$statement = $this->pdo->prepare(
'INSERT INTO email_mailboxes (name, smtp_host, smtp_port, smtp_encryption, smtp_username, smtp_password_encrypted, sender_email, sender_name, is_default, is_active)
VALUES (:name, :smtp_host, :smtp_port, :smtp_encryption, :smtp_username, :smtp_password_encrypted, :sender_email, :sender_name, :is_default, :is_active)'
);
}
$statement->execute($params);
}
public function delete(int $id): void
{
$statement = $this->pdo->prepare('DELETE FROM email_mailboxes WHERE id = :id');
$statement->execute(['id' => $id]);
}
public function toggleStatus(int $id): void
{
$statement = $this->pdo->prepare(
'UPDATE email_mailboxes SET is_active = NOT is_active WHERE id = :id'
);
$statement->execute(['id' => $id]);
}
}