Files
mailcow-dockerized/data/web/inc/lib/mailcow/README.md
T

98 lines
6.6 KiB
Markdown

# 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>/
<Module>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>Module::create()`. Procedural legacy callers (`json_api.php`, pages,
`functions.*.inc.php`) use `<Module>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 `[<legacy function>, <action>, <type>, <data>]` 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\<Module>\{Model,Repository,Service,Http,Controller}\<Class>`.
## 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>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/<Module>/...` (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`).