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:
301
src/Modules/Settings/EmailMailboxController.php
Normal file
301
src/Modules/Settings/EmailMailboxController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
152
src/Modules/Settings/EmailMailboxRepository.php
Normal file
152
src/Modules/Settings/EmailMailboxRepository.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user