# mailcow PHP classes (`Mailcow\`) New backend code of the mailcow UI/API lives here as plain PHP classes, one directory per module. It replaces the procedural `data/web/inc/functions.*.inc.php` files step by step. There is no framework, no DI container and no ORM: just small classes, constructor injection and PDO. The directory sits below `inc/lib/`, which nginx never serves. Classes are loaded by Composer's PSR-4 autoloader (`"Mailcow\\": "mailcow/"` in `inc/lib/composer.json`), which every entry point already includes (`prerequisites.inc.php`, crons, `mailcowauth.php`). ## Layout ``` Core/ shared by all modules Context.php who is calling: role, username, ACL, access-all; fromSession(), system() UserError.php expected error with a lang key (+ args), shown as a danger message AccessDenied.php UserError 'access_denied' ApiResponse.php collects messages, writes $_SESSION['return'] (the only class that does) Input.php helpers for loosely typed request values Repository.php base class of all repositories: PDO, query(), insertRow(), updateRow(), transaction() / Module.php composition root: create(PDO, Context, array $config) wires everything; fromGlobals() is the legacy entry point for procedural callers Model/ plain typed objects (fromRow(), toArray() for the API shape), no DB Repository/ SQL only (prepared statements); takes and returns models Service/ use cases, business rules, permissions (Context), transactions via repositories Http/ outbound I/O (e.g. OAuth requests) Controller/ request in, messages out: adapter for json_api.php and pages ``` Example: `Imapsync/` (sync jobs, sync sources, OAuth). Use it as the template for new modules. ## Layers and rules - **Model:** typed public properties, `fromRow()` from a DB row, `toArray()` for the API format (keys and order are the API contract). Secrets stay in the model but never in `toArray()`. No getters/setters. - **Repository:** extends `Core\Repository`; the only place with SQL (prepared statements via `query()`). No permission decisions (a visibility filter that is pure SQL may take the `Context`). A method that writes several tables wraps them in `transaction()`, which joins a transaction the caller already runs. - **Service:** one use case per public method, working on single objects. Checks permissions with the `Context` and throws `UserError` / `AccessDenied` with a lang key. No `$_SESSION`, no `global`, no output. When add and edit share their rules, use one private routine for both (e.g. `JobService::save()`, `SourceService::applyBasics()`): missing attributes keep the current value, a new object starts with defaults. - **Controller:** normalises request input (batch ids, attribute merging), loops over batch ids, calls services, turns `UserError` into messages via `ApiResponse` and returns what `json_api.php` passes to `process_*_return()`. The API format (routes, JSON, message keys and order) stays as it was. Each write method is a few lines on top of two private helpers: `handle()` (log entry, error mapping, flush) and `batch()` (one message per id). - **View:** Twig templates, unchanged. ## Conventions - `declare(strict_types=1);` in every file; typed properties, parameters and return types; constructor property promotion; `public readonly` properties instead of getters for values that never change. - Class files contain only declarations (no code at file level), one class per file. - Constructor injection. Global state (`$pdo`, `$_SESSION`, config arrays, `getenv()`) is only read at the entry points and passed to `Module::create()`. Procedural legacy callers (`json_api.php`, pages, `functions.*.inc.php`) use `Module::fromGlobals(?Context $context = null)`, the one documented place in the module that reads the globals, instead of repeating the config lines; entry points that build their own state (crons, CLI) call `create()` directly, e.g. with `Context::system()`. `ApiResponse::flush()` is the one class that writes `$_SESSION['return']`. - No interfaces until there are two implementations; no closures as injected dependencies (local callbacks such as `transaction(fn ...)` or the controller's `handle()` are fine); small methods. - Docblocks only where they add information (rules, API contract, non-obvious behaviour), not to repeat the signature. - Classes are not `final`, so PHPUnit can mock them. I/O that tests must replace sits in protected methods (see `Imapsync/Http/OAuthClient::send()`). - Messages: services throw, controllers report. The `msg` is a lang key of the matching section (`success` / `danger`), optionally with arguments: `new UserError('object_exists', [$name])`. - Logs: the `log` part of a message is `[, , , ]` as before; request keys holding secrets are masked by `ApiResponse` (configure them in the module). - A database error (`PDOException`) stops the call with one `mysql_error` message, like the legacy code. - Naming: `Mailcow\\{Model,Repository,Service,Http,Controller}\`. ## Converting a legacy `functions.X.inc.php` 1. Capture the current API behaviour (E2E runs, raw JSON responses, DB state). 2. Build the module as above: models, repositories, services, controller, `Module::create()`. 3. Switch the callers (`json_api.php`, pages, crons, other function files) to the module. 4. Delete the function file and its `require` lines. 5. Compare the API output with step 1; the format must stay the same. Large files (e.g. `functions.mailbox.inc.php`) are converted one area at a time (aliases, domains, ...), each as its own module. Until `functions.inc.php` is converted, `Context::canAccessMailbox()` / `canAccessDomain()` call the legacy `hasMailboxObjectAccess()` / `hasDomainAccess()`. ## Development - Unit tests: `tests/php//...` (dev-only, never deployed), run with `helper-scripts/dev_tests/run_php_unit_tests.sh`. Services are tested with mocked repositories; repositories are covered by the E2E suites (MariaDB-specific SQL). - Autoloader: after adding the PSR-4 mapping nothing needs regenerating for new classes. If you ever run `composer dump-autoload` (inside the php-fpm container, `-w /web/inc/lib`), never use `-o`, `-a` or `--apcu`, and commit only the changed `autoload_*.php` files. - No dev packages in `inc/lib/composer.json`. - opcache runs with `validate_timestamps=0`: restart php-fpm after changing PHP files (`docker compose restart php-fpm-mailcow`).