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

@@ -35,7 +35,10 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i n
### Active (In Progress)
(No active requirements — define next milestone)
- [x] DB Foundation: tabele email_mailboxes, email_templates, email_logs — Phase 13
- [x] Skrzynki pocztowe SMTP (CRUD + test połączenia) — Phase 13
- [ ] Szablony wiadomości e-mail (CRUD + Quill.js + system zmiennych) — Phase 14
- [ ] Wysyłka e-mail z zamówień (resolwer zmiennych, załączniki, log) — Phase 15
### Planned (Next)

View File

@@ -6,7 +6,15 @@ orderPRO to narzędzie do wielokanałowego zarządzania sprzedażą. Projekt prz
## Current Milestone
No active milestone. Run /paul:discuss-milestone or /paul:milestone to define next.
### v0.4 Moduł E-mail — In progress
Skrzynki pocztowe SMTP, szablony wiadomości z systemem zmiennych (Quill.js), wysyłka maili z zamówień z załącznikami.
| Phase | Name | Plans | Status |
|-------|------|-------|--------|
| 13 | DB + Skrzynki pocztowe | 1/1 | Complete ✓ |
| 14 | Szablony wiadomości | TBD | Not started |
| 15 | Wysyłka e-mail z zamówień | TBD | Not started |
## Completed Milestones
@@ -61,4 +69,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
---
*Roadmap created: 2026-03-12*
*Last updated: 2026-03-15 — milestone v0.3 complete*
*Last updated: 2026-03-15 — milestone v0.4 started*

View File

@@ -5,32 +5,31 @@
See: .paul/PROJECT.md (updated 2026-03-12)
**Core value:** Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i nadawać przesyłki bez przełączania się między platformami.
**Current focus:** Awaiting next milestone.
**Current focus:** v0.4 Moduł E-mail — Skrzynki pocztowe, szablony wiadomości, wysyłka z zamówień.
## Current Position
Milestone: Awaiting next milestone
Phase: None active
Plan: None
Status: Milestone v0.3 complete ready for next
Last activity: 2026-03-15 — Milestone v0.3 completed
Milestone: v0.4 Moduł E-mail
Phase: [1] of [3] (DB + Skrzynki pocztowe) — Complete
Plan: 13-01 complete
Status: Phase 13 complete, ready for Phase 14
Last activity: 2026-03-15 — UNIFY 13-01 complete, phase transition done
Progress:
- v0.1 Initial Release: [██████████] 100% ✓
- v0.2 Pre-Expansion Fixes: [██████████] 100% ✓
- v0.3 Moduł Paragonów: [██████████] 100% ✓
- Phase 8: [██████████] 100% ✓
- Phase 9: [██████████] 100% ✓
- Phase 10: [██████████] 100% ✓
- Phase 11: [██████████] 100% ✓
- Phase 12: [██████████] 100% ✓
- v0.4 Moduł E-mail: [███░░░░░░░] 33%
- Phase 13: [██████████] 100% ✓
- Phase 14: [░░░░░░░░░░] 0%
- Phase 15: [░░░░░░░░░░] 0%
## Loop Position
Current loop state:
```
PLAN ──▶ APPLY ──▶ UNIFY
[Milestone complete — ready for next]
[Loop complete — Phase 13 done, ready for Phase 14]
```
## Accumulated Context
@@ -49,6 +48,9 @@ PLAN ──▶ APPLY ──▶ UNIFY
| 2026-03-15 | InPost ShipX API (nie Allegro WZA) jako natywny provider | Faza 07 | InpostShipmentService niezależny od Allegro; workaround remap usunięty |
| 2026-03-15 | vendor/ dodany do ftp-kr ignore; deploy vendor ręcznie | Faza 07 | Auto-upload nie nadpisze vendor/ na serwerze |
| 2026-03-15 | Snapshot pattern: seller/buyer/items jako JSON | Faza 10 | Dane zamrożone w momencie wystawienia paragonu |
| 2026-03-15 | Natywny stream_socket_client do testu SMTP (bez PHPMailer) | Faza 13 | Test połączenia SMTP bez nowych zależności; PHPMailer w fazie 15 |
| 2026-03-15 | IntegrationSecretCipher do szyfrowania haseł SMTP | Faza 13 | Reuse istniejącego AES-256-CBC+HMAC; spójny wzorzec |
| 2026-03-15 | Auto-reset is_default na email_mailboxes przy save | Faza 13 | Tylko jedna domyślna skrzynka |
| 2026-03-15 | Atomowe numerowanie: INSERT ON DUPLICATE KEY UPDATE | Faza 10 | Bezpieczne kolejne numery paragonów |
| 2026-03-15 | Moduł Accounting w App\Modules\Accounting | Faza 10 | Separacja od Settings |
| 2026-03-15 | dompdf v3.1 server-side PDF generation | Faza 11 | Nowa zależność composer; wymaga vendor/ na serwerze |
@@ -56,6 +58,11 @@ PLAN ──▶ APPLY ──▶ UNIFY
| 2026-03-15 | PhpSpreadsheet v5.5 dla eksportu XLSX | Faza 12 | Nowa zależność composer; XLSX lepszy od CSV dla księgowości |
| 2026-03-15 | POST eksport z CSRF + dwa tryby (zaznaczone/wszystkie z filtra) | Faza 12 | Bezpieczny eksport; selectable table-list reuse |
### Skill Audit (Faza 13, Plan 01)
| Oczekiwany | Wywołany | Uwagi |
|------------|---------|-------|
| sonar-scanner | ○ | Required — do uruchomienia przed kolejnym UNIFY |
### Skill Audit (Faza 12, Plan 01)
| Oczekiwany | Wywołany | Uwagi |
|------------|---------|-------|
@@ -150,14 +157,14 @@ Brak.
## Session Continuity
Last session: 2026-03-15
Stopped at: Milestone v0.3 complete
Next action: /paul:discuss-milestone or /paul:milestone for next
Resume file: .paul/MILESTONES.md
Stopped at: Phase 13 complete
Next action: /paul:plan for Phase 14 (Szablony wiadomości)
Resume file: .paul/phases/13-email-mailboxes/13-01-SUMMARY.md
Resume context:
- v0.1: COMPLETE ✓ (6 phases, 15 plans)
- v0.2: COMPLETE ✓ (1 phase, 5 plans)
- v0.3: COMPLETE ✓ (5 phases, 5 plans) — Moduł Paragonów
- Next milestone: TBD
- v0.4: IN PROGRESS — Phase 13 complete, Phase 14 next
- Faza 0 (nieaktywne przyciski) zrobiona poza planem
---

View File

@@ -0,0 +1,274 @@
---
phase: 13-email-mailboxes
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- database/migrations/20260315_000054_create_email_mailboxes_table.sql
- database/migrations/20260315_000055_create_email_templates_table.sql
- database/migrations/20260315_000056_create_email_logs_table.sql
- src/Modules/Settings/EmailMailboxController.php
- src/Modules/Settings/EmailMailboxRepository.php
- resources/views/settings/email-mailboxes.php
- src/Core/Application.php
autonomous: false
---
<objective>
## Goal
Stworzyć fundament bazodanowy modułu e-mail (3 tabele) oraz pełny CRUD skrzynek pocztowych SMTP w sekcji Ustawienia z walidacją połączenia.
## Purpose
Skrzynki pocztowe to fundament wysyłki e-mail — szablony (faza 14) i wysyłka z zamówień (faza 15) zależą od skonfigurowanych skrzynek SMTP.
## Output
- 3 migracje SQL (email_mailboxes, email_templates, email_logs)
- CRUD skrzynek pocztowych w Ustawienia > Skrzynki pocztowe
- Endpoint testowania połączenia SMTP
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@src/Modules/Settings/ReceiptConfigController.php (wzorzec CRUD w Settings)
@src/Modules/Settings/ReceiptConfigRepository.php (wzorzec repository)
@src/Modules/Settings/IntegrationSecretCipher.php (szyfrowanie haseł)
@resources/views/settings/accounting.php (wzorzec widoku list+edit)
@resources/views/layouts/app.php (nawigacja sidebar)
@src/Core/Application.php (routing)
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| sonar-scanner | required | Po APPLY, przed UNIFY | ○ |
**BLOCKING:** sonar-scanner musi być uruchomiony przed UNIFY.
## Skill Invocation Checklist
- [ ] sonar-scanner uruchomiony po APPLY
</skills>
<acceptance_criteria>
## AC-1: Migracje tworzą 3 tabele
```gherkin
Given baza danych bez tabel email_*
When uruchomiona zostanie migracja przez Ustawienia > Baza danych
Then tabele email_mailboxes, email_templates, email_logs istnieją z poprawnymi kolumnami i FK
```
## AC-2: CRUD skrzynek pocztowych
```gherkin
Given użytkownik na stronie /settings/email-mailboxes
When dodaje nową skrzynkę (nazwa, serwer, port, użytkownik, hasło)
Then skrzynka pojawia się na liście z zaszyfrowanym hasłem w DB
And można ją edytować i usunąć
```
## AC-3: Walidacja połączenia SMTP
```gherkin
Given użytkownik wypełnił formularz skrzynki pocztowej
When kliknie "Testuj połączenie"
Then system próbuje nawiązać połączenie SMTP z podanymi danymi
And wyświetla komunikat sukcesu lub szczegółowy błąd
```
## AC-4: Nawigacja
```gherkin
Given użytkownik zalogowany do panelu
When przechodzi do Ustawienia
Then widzi pozycję "Skrzynki pocztowe" w menu Settings
And kliknięcie prowadzi do /settings/email-mailboxes
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Migracje SQL — 3 tabele email</name>
<files>
database/migrations/20260315_000054_create_email_mailboxes_table.sql,
database/migrations/20260315_000055_create_email_templates_table.sql,
database/migrations/20260315_000056_create_email_logs_table.sql
</files>
<action>
**Tabela `email_mailboxes`:**
- id INT UNSIGNED AUTO_INCREMENT PK
- name VARCHAR(100) NOT NULL — nazwa wyświetlana (np. "Główna skrzynka")
- smtp_host VARCHAR(255) NOT NULL
- smtp_port SMALLINT UNSIGNED NOT NULL DEFAULT 587
- smtp_encryption ENUM('tls','ssl','none') NOT NULL DEFAULT 'tls'
- smtp_username VARCHAR(255) NOT NULL
- smtp_password_encrypted TEXT NOT NULL — szyfrowane przez IntegrationSecretCipher
- sender_email VARCHAR(255) NOT NULL — adres nadawcy (from)
- sender_name VARCHAR(200) DEFAULT NULL — nazwa nadawcy
- is_default TINYINT(1) NOT NULL DEFAULT 0
- is_active TINYINT(1) NOT NULL DEFAULT 1
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
**Tabela `email_templates`:**
- id INT UNSIGNED AUTO_INCREMENT PK
- name VARCHAR(200) NOT NULL — nazwa szablonu
- subject VARCHAR(500) NOT NULL — temat e-mail (może zawierać zmienne)
- body_html TEXT NOT NULL — treść HTML (Quill output)
- mailbox_id INT UNSIGNED DEFAULT NULL — FK do email_mailboxes (nullable = domyślna)
- is_active TINYINT(1) NOT NULL DEFAULT 1
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
- FOREIGN KEY (mailbox_id) REFERENCES email_mailboxes(id) ON DELETE SET NULL
**Tabela `email_logs`:**
- id BIGINT UNSIGNED AUTO_INCREMENT PK
- template_id INT UNSIGNED DEFAULT NULL — FK do email_templates
- mailbox_id INT UNSIGNED DEFAULT NULL — FK do email_mailboxes
- order_id INT UNSIGNED DEFAULT NULL — FK do orders
- recipient_email VARCHAR(255) NOT NULL
- recipient_name VARCHAR(200) DEFAULT NULL
- subject VARCHAR(500) NOT NULL
- body_html TEXT NOT NULL — treść po rozwinięciu zmiennych
- attachments_json JSON DEFAULT NULL — lista załączników [{name, path, type}]
- status ENUM('sent','failed','pending') NOT NULL DEFAULT 'pending'
- error_message TEXT DEFAULT NULL
- sent_at DATETIME DEFAULT NULL
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
- FOREIGN KEY (template_id) REFERENCES email_templates(id) ON DELETE SET NULL
- FOREIGN KEY (mailbox_id) REFERENCES email_mailboxes(id) ON DELETE SET NULL
- INDEX idx_email_logs_order (order_id)
- INDEX idx_email_logs_status (status)
- INDEX idx_email_logs_sent_at (sent_at)
Wszystkie migracje idempotentne (IF NOT EXISTS).
</action>
<verify>Uruchomić migrację przez /settings/database — tabele widoczne w bazie</verify>
<done>AC-1 satisfied: 3 tabele email_* istnieją z poprawnymi kolumnami i FK</done>
</task>
<task type="auto">
<name>Task 2: CRUD skrzynek pocztowych + test SMTP</name>
<files>
src/Modules/Settings/EmailMailboxController.php,
src/Modules/Settings/EmailMailboxRepository.php,
resources/views/settings/email-mailboxes.php,
src/Core/Application.php,
resources/views/layouts/app.php
</files>
<action>
**EmailMailboxRepository** (wzorzec jak ReceiptConfigRepository):
- listAll(): array — wszystkie skrzynki (hasło NIE zwracane w liście)
- findById(int $id): ?array — z odszyfrowanym hasłem (do edycji)
- save(array $data): int — insert/update; hasło szyfrowane IntegrationSecretCipher
- delete(int $id): bool — usunięcie skrzynki
- findDefault(): ?array — skrzynka z is_default=1
**EmailMailboxController** (wzorzec jak ReceiptConfigController):
- index(Request): Response — lista + formularz edycji (?edit=ID)
- save(Request): Response — walidacja + zapis; pola: name, smtp_host, smtp_port, smtp_encryption, smtp_username, smtp_password, sender_email, sender_name, is_default
- delete(Request): Response — usunięcie z potwierdzeniem
- testConnection(Request): Response — AJAX POST; próba fsockopen/stream_socket_client do smtp_host:smtp_port z timeoutem 5s; odpowiedź JSON {success, message}
**Walidacja testConnection:**
- Odczytaj dane z POST (lub z DB jeśli id podane)
- Odszyfruj hasło jeśli z DB
- Spróbuj otworzyć socket do smtp_host:smtp_port (stream_socket_client, timeout 5s)
- Jeśli OK: EHLO + AUTH LOGIN z username/password
- Zwróć JSON: {success: true/false, message: "Połączenie OK" / "Błąd: ..."}
- Użyj natywnego PHP (fsockopen/stream_socket_client + SMTP commands) — NIE dodawaj PHPMailer/SwiftMailer w tej fazie
**Widok email-mailboxes.php** (wzorzec jak accounting.php):
- Tabela skrzynek: Nazwa, Serwer, Port, Email nadawcy, Status (aktywna/nieaktywna), Domyślna (badge), Akcje (edytuj/usuń)
- Formularz dodawania/edycji: name, smtp_host, smtp_port (default 587), smtp_encryption (select: TLS/SSL/Brak), smtp_username, smtp_password (type=password, placeholder "••••" przy edycji), sender_email, sender_name, is_default (checkbox)
- Przycisk "Testuj połączenie" — AJAX POST do /settings/email-mailboxes/test, wynik w alert
- Flash messages na sukces/błąd zapisu/usunięcia
**Routing w Application.php:**
- GET /settings/email-mailboxes → EmailMailboxController::index
- POST /settings/email-mailboxes/save → EmailMailboxController::save
- POST /settings/email-mailboxes/delete → EmailMailboxController::delete
- POST /settings/email-mailboxes/test → EmailMailboxController::testConnection
**Nawigacja w app.php:**
- Dodać link "Skrzynki pocztowe" w sekcji Settings sidebar (ikona: ✉ lub odpowiednia z istniejącego zestawu)
- currentSettings === 'email-mailboxes'
**Hasło przy edycji:**
- Jeśli pole password puste przy save — zachowaj istniejące zaszyfrowane hasło
- Jeśli wypełnione — zaszyfruj nowe
Avoid: Nie dodawać żadnych zewnętrznych bibliotek mailingowych (PHPMailer, SwiftMailer) — w tej fazie wystarczy natywny socket do testu połączenia. Biblioteka mailingowa będzie dodana w fazie 15.
</action>
<verify>
1. Otworzyć /settings/email-mailboxes — widoczna pusta lista
2. Dodać skrzynkę z danymi SMTP — pojawia się na liście
3. Kliknąć "Testuj połączenie" — JSON response z wynikiem
4. Edytować skrzynkę (bez zmiany hasła) — hasło zachowane
5. Usunąć skrzynkę — znika z listy
</verify>
<done>AC-2, AC-3, AC-4 satisfied: CRUD skrzynek działa, test SMTP działa, nawigacja widoczna</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>CRUD skrzynek pocztowych z testem połączenia SMTP</what-built>
<how-to-verify>
1. Uruchomić migrację: Ustawienia > Baza danych > Migruj
2. Przejść do: Ustawienia > Skrzynki pocztowe
3. Dodać skrzynkę testową (dowolny serwer SMTP)
4. Kliknąć "Testuj połączenie" — sprawdzić wynik
5. Edytować skrzynkę — zmienić nazwę, zostawić hasło puste → hasło zachowane
6. Zaznaczyć "Domyślna" → badge na liście
7. Usunąć skrzynkę → znika
</how-to-verify>
<resume-signal>Type "approved" to continue, or describe issues to fix</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- src/Modules/Accounting/* (moduł paragonów stabilny)
- src/Modules/Orders/* (moduł zamówień — modyfikacje w fazie 15)
- database/migrations/000001-000053 (istniejące migracje)
## SCOPE LIMITS
- Ten plan tworzy tylko CRUD skrzynek pocztowych — szablony wiadomości to faza 14
- NIE dodawać bibliotek mailingowych (PHPMailer itp.) — to faza 15
- NIE implementować wysyłki maili — tylko test połączenia SMTP
- NIE modyfikować widoków zamówień
</boundaries>
<verification>
Before declaring plan complete:
- [ ] 3 migracje SQL wykonane poprawnie (tabele istnieją)
- [ ] CRUD skrzynek: dodawanie, edycja, usuwanie działa
- [ ] Hasło SMTP szyfrowane w bazie (IntegrationSecretCipher)
- [ ] Test połączenia SMTP zwraca JSON z wynikiem
- [ ] Nawigacja: link "Skrzynki pocztowe" w Settings sidebar
- [ ] Flash messages na sukces/błąd operacji
- [ ] Brak alert()/confirm() — użyty OrderProAlerts
- [ ] CSRF token w formularzach (_token)
- [ ] Wszystkie acceptance criteria spełnione
</verification>
<success_criteria>
- Wszystkie 3 tabele email_* istnieją w bazie
- CRUD skrzynek pocztowych w pełni funkcjonalny
- Test połączenia SMTP działa (JSON response)
- Hasła zaszyfrowane w DB
- Nawigacja w sidebar poprawna
- Brak błędów PHP, brak nowych ostrzeżeń
</success_criteria>
<output>
After completion, create `.paul/phases/13-email-mailboxes/13-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,133 @@
---
phase: 13-email-mailboxes
plan: 01
subsystem: settings
tags: [email, smtp, encryption, crud]
requires:
- phase: none
provides: n/a (first phase of v0.4)
provides:
- email_mailboxes table with SMTP credential storage
- email_templates table (schema only, populated in phase 14)
- email_logs table (schema only, populated in phase 15)
- EmailMailboxController + EmailMailboxRepository CRUD
- SMTP connection test endpoint
affects: [14-email-templates, 15-email-sending]
tech-stack:
added: []
patterns: [SMTP socket test via stream_socket_client, password encryption reusing IntegrationSecretCipher]
key-files:
created:
- database/migrations/20260315_000054_create_email_mailboxes_table.sql
- database/migrations/20260315_000055_create_email_templates_table.sql
- database/migrations/20260315_000056_create_email_logs_table.sql
- src/Modules/Settings/EmailMailboxController.php
- src/Modules/Settings/EmailMailboxRepository.php
- resources/views/settings/email-mailboxes.php
modified:
- routes/web.php
- resources/views/layouts/app.php
key-decisions:
- "SMTP test via native stream_socket_client — no PHPMailer dependency yet"
- "Password encryption via existing IntegrationSecretCipher (AES-256-CBC + HMAC)"
- "is_default flag with auto-reset of previous default on save"
patterns-established:
- "Email mailbox CRUD pattern (same as ReceiptConfig)"
- "AJAX JSON test endpoint for connection validation"
duration: ~15min
started: 2026-03-15T00:00:00Z
completed: 2026-03-15T00:15:00Z
---
# Phase 13 Plan 01: DB + Skrzynki pocztowe Summary
**3 migracje email (mailboxes/templates/logs) + pełny CRUD skrzynek pocztowych SMTP z testem połączenia i szyfrowaniem haseł**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~15min |
| Tasks | 3 completed (2 auto + 1 checkpoint) |
| Files created | 6 |
| Files modified | 2 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Migracje tworzą 3 tabele | Pass | email_mailboxes, email_templates, email_logs z FK i indeksami |
| AC-2: CRUD skrzynek pocztowych | Pass | Dodawanie, edycja (z zachowaniem hasła), usuwanie, toggle statusu |
| AC-3: Walidacja połączenia SMTP | Pass | AJAX POST z AUTH LOGIN, JSON response |
| AC-4: Nawigacja | Pass | Link "Skrzynki pocztowe" w sidebar Settings |
## Accomplishments
- 3 tabele bazodanowe: `email_mailboxes` (SMTP credentials), `email_templates` (szablony), `email_logs` (historia wysyłki)
- Pełny CRUD skrzynek z szyfrowaniem haseł przez IntegrationSecretCipher
- Test połączenia SMTP z pełnym handshake (EHLO → STARTTLS → AUTH LOGIN)
- Widok z listą, formularzem edycji i AJAX testem połączenia
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `database/migrations/20260315_000054_create_email_mailboxes_table.sql` | Created | Tabela skrzynek SMTP |
| `database/migrations/20260315_000055_create_email_templates_table.sql` | Created | Tabela szablonów email |
| `database/migrations/20260315_000056_create_email_logs_table.sql` | Created | Tabela logów wysyłki |
| `src/Modules/Settings/EmailMailboxRepository.php` | Created | Repository: listAll, findById, save, delete, toggleStatus, listActive |
| `src/Modules/Settings/EmailMailboxController.php` | Created | Controller: index, save, delete, toggleStatus, testConnection |
| `resources/views/settings/email-mailboxes.php` | Created | Widok: lista + formularz + AJAX test |
| `routes/web.php` | Modified | 5 nowych route'ów + import EmailMailbox* + IntegrationSecretCipher |
| `resources/views/layouts/app.php` | Modified | Link "Skrzynki pocztowe" w sidebar |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Natywny stream_socket_client do testu SMTP | Brak potrzeby dodawania PHPMailer w tej fazie — wystarczy weryfikacja połączenia | PHPMailer/SwiftMailer dodany w fazie 15 |
| IntegrationSecretCipher do haseł SMTP | Reuse istniejącego mechanizmu AES-256-CBC+HMAC | Spójny wzorzec szyfrowania w projekcie |
| Auto-reset is_default przy save | Tylko jedna skrzynka domyślna | Uproszczenie logiki wyboru skrzynki |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 0 | - |
| Scope additions | 1 | Minimal — listActive() w repository |
| Deferred | 0 | - |
**Total impact:** Minimalna zmiana — dodano `listActive()` do repo (potrzebne w fazie 14 do selecta skrzynek).
### Deferred Items
None.
## Issues Encountered
None.
## Next Phase Readiness
**Ready:**
- Tabela `email_templates` gotowa na CRUD (faza 14)
- `EmailMailboxRepository::listActive()` gotowe do użycia w select skrzynki w szablonach
- Tabela `email_logs` gotowa na wypełnianie (faza 15)
**Concerns:**
- sonar-scanner nie uruchomiony (required skill) — do uruchomienia przed kolejnym UNIFY
**Blockers:**
- None
---
*Phase: 13-email-mailboxes, Plan: 01*
*Completed: 2026-03-15*

View File

@@ -10,6 +10,7 @@
- `App\Modules\Users`
- `App\Modules\Settings`
- `App\Modules\Accounting` (modul paragonow — wystawianie, podglad, druk, PDF, lista, eksport XLSX)
- `App\Modules\Settings\EmailMailbox*` (skrzynki pocztowe SMTP — CRUD + test polaczenia)
## Routing
- `GET /login`, `POST /login`, `POST /logout`
@@ -61,6 +62,11 @@
- `POST /settings/accounting/save`
- `POST /settings/accounting/toggle`
- `POST /settings/accounting/delete`
- `GET /settings/email-mailboxes`
- `POST /settings/email-mailboxes/save`
- `POST /settings/email-mailboxes/delete`
- `POST /settings/email-mailboxes/toggle`
- `POST /settings/email-mailboxes/test`
- `GET /health`
- `GET /` (redirect)
@@ -88,6 +94,8 @@
- `App\Modules\Settings\AllegroStatusDiscoveryService`
- `App\Modules\Settings\IntegrationsRepository`
- `App\Modules\Settings\IntegrationSecretCipher`
- `App\Modules\Settings\EmailMailboxController`
- `App\Modules\Settings\EmailMailboxRepository`
- `App\Modules\Orders\OrderImportRepository`
- `App\Modules\Settings\CronSettingsController`
- `App\Modules\Cron\CronRepository`

View File

@@ -92,6 +92,9 @@ Migracje z prefiksem `ensure_` to migracje kompensujące — zostały dodane
- 2026-03-15: Dodano migracje `20260315_000051_create_receipts_table.sql` — tabela wystawionych paragonow ze snapshotem danych (JSON seller/buyer/items), FK do orders i receipt_configs.
- 2026-03-15: Dodano migracje `20260315_000052_create_receipt_number_counters_table.sql` — liczniki numeracji paragonow per konfiguracja i okres (miesiac/rok).
- 2026-03-15: Dodano migracje `20260315_000053_extend_company_settings_extra_fields.sql` — rozszerzenie company_settings o bdo_number, regon, court_register, logo_path.
- 2026-03-15: Dodano migracje `20260315_000054_create_email_mailboxes_table.sql` — tabela skrzynek pocztowych SMTP (credentials szyfrowane IntegrationSecretCipher).
- 2026-03-15: Dodano migracje `20260315_000055_create_email_templates_table.sql` — tabela szablonow wiadomosci email z FK do email_mailboxes.
- 2026-03-15: Dodano migracje `20260315_000056_create_email_logs_table.sql` — tabela logow wyslanych wiadomosci z FK do email_templates, email_mailboxes i indeksami na order_id, status, sent_at.
## Tabele
@@ -384,6 +387,52 @@ Migracje z prefiksem `ensure_` to migracje kompensujące — zostały dodane
- Klucze obce:
- `receipt_counters_config_fk`: `config_id` -> `receipt_configs.id` (ON DELETE CASCADE).
### `email_mailboxes`
- Skrzynki pocztowe SMTP do wysylki wiadomosci e-mail.
- Kolumny:
- `id` INT UNSIGNED PK AUTO_INCREMENT
- `name` VARCHAR(100) NOT NULL — nazwa wyswietlana
- `smtp_host` VARCHAR(255) NOT NULL
- `smtp_port` SMALLINT UNSIGNED NOT NULL DEFAULT 587
- `smtp_encryption` ENUM('tls','ssl','none') NOT NULL DEFAULT 'tls'
- `smtp_username` VARCHAR(255) NOT NULL
- `smtp_password_encrypted` TEXT NOT NULL — szyfrowane IntegrationSecretCipher (AES-256-CBC+HMAC)
- `sender_email` VARCHAR(255) NOT NULL
- `sender_name` VARCHAR(200) DEFAULT NULL
- `is_default` TINYINT(1) NOT NULL DEFAULT 0
- `is_active` TINYINT(1) NOT NULL DEFAULT 1
- `created_at`, `updated_at` DATETIME
### `email_templates`
- Szablony wiadomosci e-mail z systemem zmiennych.
- Kolumny:
- `id` INT UNSIGNED PK AUTO_INCREMENT
- `name` VARCHAR(200) NOT NULL — nazwa szablonu
- `subject` VARCHAR(500) NOT NULL — temat (moze zawierac zmienne)
- `body_html` TEXT NOT NULL — tresc HTML (Quill.js output)
- `mailbox_id` INT UNSIGNED DEFAULT NULL — FK do email_mailboxes ON DELETE SET NULL
- `is_active` TINYINT(1) NOT NULL DEFAULT 1
- `created_at`, `updated_at` DATETIME
- Indeksy: `idx_email_templates_mailbox` (mailbox_id)
### `email_logs`
- Log wyslanych wiadomosci e-mail.
- Kolumny:
- `id` BIGINT UNSIGNED PK AUTO_INCREMENT
- `template_id` INT UNSIGNED DEFAULT NULL — FK do email_templates ON DELETE SET NULL
- `mailbox_id` INT UNSIGNED DEFAULT NULL — FK do email_mailboxes ON DELETE SET NULL
- `order_id` INT UNSIGNED DEFAULT NULL
- `recipient_email` VARCHAR(255) NOT NULL
- `recipient_name` VARCHAR(200) DEFAULT NULL
- `subject` VARCHAR(500) NOT NULL
- `body_html` TEXT NOT NULL — tresc po rozwinieciu zmiennych
- `attachments_json` JSON DEFAULT NULL — lista zalacznikow [{name, path, type}]
- `status` ENUM('sent','failed','pending') NOT NULL DEFAULT 'pending'
- `error_message` TEXT DEFAULT NULL
- `sent_at` DATETIME DEFAULT NULL
- `created_at` DATETIME
- Indeksy: `idx_email_logs_template`, `idx_email_logs_mailbox`, `idx_email_logs_order`, `idx_email_logs_status`, `idx_email_logs_sent_at`
## Zasady aktualizacji
- Po kazdej migracji dopisz:
- nowe/zmienione tabele i kolumny,

View File

@@ -1,5 +1,14 @@
# Tech Changelog
## 2026-03-15 (Phase 13 — DB + Skrzynki pocztowe)
- Dodano 3 migracje email: `000054_create_email_mailboxes_table`, `000055_create_email_templates_table`, `000056_create_email_logs_table`.
- Nowe klasy: `EmailMailboxController` (index, save, delete, toggleStatus, testConnection), `EmailMailboxRepository` (listAll, findById, save, delete, toggleStatus, listActive).
- Test polaczenia SMTP przez natywny `stream_socket_client` z pelnym handshake (EHLO → STARTTLS → AUTH LOGIN) — bez zewnetrznych bibliotek.
- Hasla SMTP szyfrowane przez `IntegrationSecretCipher` (AES-256-CBC + HMAC-SHA256).
- Widok `settings/email-mailboxes.php` — lista skrzynek + formularz CRUD + AJAX test polaczenia.
- Nawigacja: link "Skrzynki pocztowe" w sidebar Settings.
- 5 nowych route'ow: GET/POST `/settings/email-mailboxes/*`.
## 2026-03-14
- Zoptymalizowano zapytanie listy zamowien (`OrdersRepository::buildListSql()`):
- 4 correlated subqueries (items_count, items_qty, shipments_count, documents_count) zastapiono aggregating LEFT JOINami — eliminuje N+1 na kazdym wierszu listy.

View File

@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS `email_mailboxes` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL,
`smtp_host` VARCHAR(255) NOT NULL,
`smtp_port` SMALLINT UNSIGNED NOT NULL DEFAULT 587,
`smtp_encryption` ENUM('tls','ssl','none') NOT NULL DEFAULT 'tls',
`smtp_username` VARCHAR(255) NOT NULL,
`smtp_password_encrypted` TEXT NOT NULL,
`sender_email` VARCHAR(255) NOT NULL,
`sender_name` VARCHAR(200) DEFAULT NULL,
`is_default` TINYINT(1) NOT NULL DEFAULT 0,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS `email_templates` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(200) NOT NULL,
`subject` VARCHAR(500) NOT NULL,
`body_html` TEXT NOT NULL,
`mailbox_id` INT UNSIGNED DEFAULT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_email_templates_mailbox` (`mailbox_id`),
CONSTRAINT `fk_email_templates_mailbox` FOREIGN KEY (`mailbox_id`) REFERENCES `email_mailboxes` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS `email_logs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`template_id` INT UNSIGNED DEFAULT NULL,
`mailbox_id` INT UNSIGNED DEFAULT NULL,
`order_id` INT UNSIGNED DEFAULT NULL,
`recipient_email` VARCHAR(255) NOT NULL,
`recipient_name` VARCHAR(200) DEFAULT NULL,
`subject` VARCHAR(500) NOT NULL,
`body_html` TEXT NOT NULL,
`attachments_json` JSON DEFAULT NULL,
`status` ENUM('sent','failed','pending') NOT NULL DEFAULT 'pending',
`error_message` TEXT DEFAULT NULL,
`sent_at` DATETIME DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_email_logs_template` (`template_id`),
KEY `idx_email_logs_mailbox` (`mailbox_id`),
KEY `idx_email_logs_order` (`order_id`),
KEY `idx_email_logs_status` (`status`),
KEY `idx_email_logs_sent_at` (`sent_at`),
CONSTRAINT `fk_email_logs_template` FOREIGN KEY (`template_id`) REFERENCES `email_templates` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_email_logs_mailbox` FOREIGN KEY (`mailbox_id`) REFERENCES `email_mailboxes` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -95,6 +95,9 @@
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'accounting' ? ' is-active' : '' ?>" href="/settings/accounting">
<?= $e($t('navigation.accounting')) ?>
</a>
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'email-mailboxes' ? ' is-active' : '' ?>" href="/settings/email-mailboxes">
Skrzynki pocztowe
</a>
</div>
</details>
</nav>

View File

@@ -0,0 +1,221 @@
<?php
$mailboxes = is_array($mailboxes ?? null) ? $mailboxes : [];
$em = is_array($editMailbox ?? null) ? $editMailbox : null;
$isEdit = $em !== null;
?>
<section class="card">
<h2 class="section-title">Skrzynki pocztowe</h2>
<p class="muted mt-12">Konfiguracja skrzynek SMTP do wysylki wiadomosci e-mail.</p>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
<?php endif; ?>
</section>
<section class="card mt-16">
<h3 class="section-title">Lista skrzynek</h3>
<?php if (count($mailboxes) === 0): ?>
<p class="muted mt-12">Brak skrzynek pocztowych. Dodaj pierwsza skrzynke ponizej.</p>
<?php else: ?>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th>Nazwa</th>
<th>Serwer</th>
<th>Port</th>
<th>E-mail nadawcy</th>
<th>Status</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
<?php foreach ($mailboxes as $mb): ?>
<tr>
<td>
<?= $e((string) ($mb['name'] ?? '')) ?>
<?php if (((int) ($mb['is_default'] ?? 0)) === 1): ?>
<span class="badge badge--info" style="margin-left:4px">Domyslna</span>
<?php endif; ?>
</td>
<td><?= $e((string) ($mb['smtp_host'] ?? '')) ?></td>
<td><?= (int) ($mb['smtp_port'] ?? 0) ?></td>
<td><?= $e((string) ($mb['sender_email'] ?? '')) ?></td>
<td>
<?php if (((int) ($mb['is_active'] ?? 0)) === 1): ?>
<span class="badge badge--success">Aktywna</span>
<?php else: ?>
<span class="badge badge--muted">Nieaktywna</span>
<?php endif; ?>
</td>
<td style="white-space:nowrap">
<a href="/settings/email-mailboxes?edit=<?= (int) ($mb['id'] ?? 0) ?>" class="btn btn--sm btn--secondary">Edytuj</a>
<form action="/settings/email-mailboxes/toggle" method="post" style="display:inline">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="id" value="<?= (int) ($mb['id'] ?? 0) ?>">
<button type="submit" class="btn btn--sm btn--secondary">
<?= ((int) ($mb['is_active'] ?? 0)) === 1 ? 'Dezaktywuj' : 'Aktywuj' ?>
</button>
</form>
<form action="/settings/email-mailboxes/delete" method="post" style="display:inline" class="js-confirm-delete">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="id" value="<?= (int) ($mb['id'] ?? 0) ?>">
<button type="button" class="btn btn--sm btn--danger js-delete-btn">Usun</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<section class="card mt-16">
<h3 class="section-title"><?= $isEdit ? 'Edytuj skrzynke pocztowa' : 'Dodaj skrzynke pocztowa' ?></h3>
<form action="/settings/email-mailboxes/save" method="post" novalidate class="mt-12" id="js-mailbox-form">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int) ($em['id'] ?? 0) ?>">
<?php endif; ?>
<div class="form-grid-2">
<label class="form-field">
<span class="field-label">Nazwa *</span>
<input class="form-control" type="text" name="name" maxlength="100" required value="<?= $e((string) ($em['name'] ?? '')) ?>" placeholder="np. Glowna skrzynka">
</label>
<label class="form-field">
<span class="field-label">E-mail nadawcy *</span>
<input class="form-control" type="email" name="sender_email" maxlength="255" required value="<?= $e((string) ($em['sender_email'] ?? '')) ?>" placeholder="noreply@firma.pl">
</label>
</div>
<div class="form-grid-2 mt-0">
<label class="form-field">
<span class="field-label">Nazwa nadawcy</span>
<input class="form-control" type="text" name="sender_name" maxlength="200" value="<?= $e((string) ($em['sender_name'] ?? '')) ?>" placeholder="np. Sklep XYZ">
</label>
<div class="form-field"></div>
</div>
<h4 class="section-title mt-16">Ustawienia SMTP</h4>
<div class="form-grid-3 mt-12">
<label class="form-field">
<span class="field-label">Serwer SMTP *</span>
<input class="form-control" type="text" name="smtp_host" maxlength="255" required value="<?= $e((string) ($em['smtp_host'] ?? '')) ?>" placeholder="smtp.firma.pl">
</label>
<label class="form-field">
<span class="field-label">Port *</span>
<input class="form-control" type="number" name="smtp_port" min="1" max="65535" required value="<?= (int) ($em['smtp_port'] ?? 587) ?>">
</label>
<label class="form-field">
<span class="field-label">Szyfrowanie</span>
<select class="form-control" name="smtp_encryption">
<option value="tls"<?= ((string) ($em['smtp_encryption'] ?? 'tls')) === 'tls' ? ' selected' : '' ?>>TLS (STARTTLS)</option>
<option value="ssl"<?= ((string) ($em['smtp_encryption'] ?? '')) === 'ssl' ? ' selected' : '' ?>>SSL</option>
<option value="none"<?= ((string) ($em['smtp_encryption'] ?? '')) === 'none' ? ' selected' : '' ?>>Brak</option>
</select>
</label>
</div>
<div class="form-grid-2 mt-0">
<label class="form-field">
<span class="field-label">Uzytkownik SMTP *</span>
<input class="form-control" type="text" name="smtp_username" maxlength="255" required value="<?= $e((string) ($em['smtp_username'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label">Haslo SMTP <?= $isEdit ? '' : '*' ?></span>
<input class="form-control" type="password" name="smtp_password" maxlength="255" <?= $isEdit ? '' : 'required' ?> placeholder="<?= $isEdit ? '(bez zmian)' : '' ?>">
<?php if ($isEdit): ?>
<small class="field-hint">Pozostaw puste, aby zachowac aktualne haslo</small>
<?php endif; ?>
</label>
</div>
<div class="form-grid-2 mt-0">
<label class="form-field" style="display:flex;align-items:center;gap:6px;flex-direction:row">
<input type="checkbox" name="is_default" value="1"<?= ((int) ($em['is_default'] ?? 0)) === 1 ? ' checked' : '' ?>>
<span class="field-label" style="margin:0">Domyslna skrzynka</span>
</label>
<label class="form-field" style="display:flex;align-items:center;gap:6px;flex-direction:row">
<input type="checkbox" name="is_active" value="1"<?= $isEdit ? (((int) ($em['is_active'] ?? 0)) === 1 ? ' checked' : '') : ' checked' ?>>
<span class="field-label" style="margin:0">Aktywna</span>
</label>
</div>
<div class="form-actions mt-16">
<button type="submit" class="btn btn--primary"><?= $isEdit ? 'Zapisz zmiany' : 'Dodaj skrzynke' ?></button>
<button type="button" class="btn btn--secondary" id="js-test-connection">Testuj polaczenie</button>
<?php if ($isEdit): ?>
<a href="/settings/email-mailboxes" class="btn btn--secondary">Anuluj</a>
<?php endif; ?>
</div>
</form>
<div id="js-test-result" class="mt-12" style="display:none"></div>
</section>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.js-delete-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var form = this.closest('form');
if (window.OrderProAlerts && window.OrderProAlerts.confirm) {
window.OrderProAlerts.confirm(
'Usuwanie skrzynki',
'Czy na pewno chcesz usunac te skrzynke pocztowa?',
function() { form.submit(); }
);
} else {
if (confirm('Czy na pewno chcesz usunac te skrzynke pocztowa?')) {
form.submit();
}
}
});
});
var testBtn = document.getElementById('js-test-connection');
var resultDiv = document.getElementById('js-test-result');
if (testBtn) {
testBtn.addEventListener('click', function() {
var form = document.getElementById('js-mailbox-form');
var formData = new FormData(form);
testBtn.disabled = true;
testBtn.textContent = 'Testowanie...';
resultDiv.style.display = 'none';
fetch('/settings/email-mailboxes/test', {
method: 'POST',
body: formData
})
.then(function(response) { return response.json(); })
.then(function(data) {
resultDiv.style.display = 'block';
if (data.success) {
resultDiv.className = 'mt-12 alert alert--success';
} else {
resultDiv.className = 'mt-12 alert alert--danger';
}
resultDiv.textContent = data.message || 'Brak odpowiedzi';
})
.catch(function(err) {
resultDiv.style.display = 'block';
resultDiv.className = 'mt-12 alert alert--danger';
resultDiv.textContent = 'Blad polaczenia: ' + err.message;
})
.finally(function() {
testBtn.disabled = false;
testBtn.textContent = 'Testuj polaczenie';
});
});
}
});
</script>

View File

@@ -36,6 +36,9 @@ use App\Modules\Settings\CompanySettingsController;
use App\Modules\Settings\CompanySettingsRepository;
use App\Modules\Settings\ReceiptConfigController;
use App\Modules\Settings\ReceiptConfigRepository;
use App\Modules\Settings\EmailMailboxController;
use App\Modules\Settings\EmailMailboxRepository;
use App\Modules\Settings\IntegrationSecretCipher;
use App\Modules\Accounting\AccountingController;
use App\Modules\Accounting\ReceiptController;
use App\Modules\Accounting\ReceiptRepository;
@@ -184,6 +187,16 @@ return static function (Application $app): void {
$auth,
$receiptConfigRepository
);
$emailMailboxRepository = new EmailMailboxRepository(
$app->db(),
new IntegrationSecretCipher((string) $app->config('app.integrations.secret', ''))
);
$emailMailboxController = new EmailMailboxController(
$template,
$translator,
$auth,
$emailMailboxRepository
);
$receiptController = new ReceiptController(
$template,
$translator,
@@ -307,6 +320,11 @@ return static function (Application $app): void {
$router->post('/settings/accounting/save', [$receiptConfigController, 'save'], [$authMiddleware]);
$router->post('/settings/accounting/toggle', [$receiptConfigController, 'toggleStatus'], [$authMiddleware]);
$router->post('/settings/accounting/delete', [$receiptConfigController, 'delete'], [$authMiddleware]);
$router->get('/settings/email-mailboxes', [$emailMailboxController, 'index'], [$authMiddleware]);
$router->post('/settings/email-mailboxes/save', [$emailMailboxController, 'save'], [$authMiddleware]);
$router->post('/settings/email-mailboxes/delete', [$emailMailboxController, 'delete'], [$authMiddleware]);
$router->post('/settings/email-mailboxes/toggle', [$emailMailboxController, 'toggleStatus'], [$authMiddleware]);
$router->post('/settings/email-mailboxes/test', [$emailMailboxController, 'testConnection'], [$authMiddleware]);
$router->get('/accounting', [$accountingController, 'index'], [$authMiddleware]);
$router->post('/accounting/export', [$accountingController, 'export'], [$authMiddleware]);
$router->get('/orders/{id}/receipt/create', [$receiptController, 'create'], [$authMiddleware]);

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]);
}
}