README.md
Rama master · réplica de lectura del repositorio original.
# Emailer
[](https://github.com/Xakki/emailer/actions/workflows/ci.yml)
[](https://www.php.net/)
[](LICENSE)
Self‑hosted transactional & notification email service for PHP. It stores projects,
campaigns, templates and recipients in a relational database, renders HTML emails
from reusable template blocks, sends them over SMTP (PHPMailer), and tracks opens,
clicks and (un)subscriptions through a small set of HTTP endpoints.
> The public surface is a plain PHP library (`Xakki\Emailer\Emailer`) plus an
> HTTP front controller and a console runner. There is no framework lock‑in — you
> wire it into your own app, container, or the bundled Docker stack.
## Features
- **Projects → campaigns → templates → queue** domain model on top of Doctrine DBAL 4.
- **Template engine** with `{{placeholder}}` substitution, reusable wrapper / content / block templates and per‑project parameters.
- **SMTP delivery** via PHPMailer with DKIM support and SMTP error classification (spam / quota / invalid mailbox / temporary, …).
- **Open & click tracking**: tracking pixel, link rewriting, and per‑recipient statistics.
- **Subscription management**: one‑click subscribe / unsubscribe endpoints, `List-Unsubscribe` header.
- **HTTP API** (Phroute router) and a **console runner** for queue processing and migrations.
- **Redis** caching for MX lookups and auth tokens; **Doctrine Migrations** for schema.
- Tested on **PHP 8.4 and 8.5**, static‑analysed with PHPStan (level 7) and PSR‑12 (strict).
## Requirements
- PHP **>= 8.4** with extensions: `pdo`, `pdo_mysql`, `json`, `fileinfo`, `redis`, `intl`, `mbstring`
- A MySQL / MariaDB database
- Redis
- Composer 2
## Installation
```bash
composer require xakki/emailer
```
Run the database migrations (against your configured connection):
```bash
./console migrations migrate
```
## Configuration
Configuration is a plain PHP array passed to `ConfigService`. Only `db.password`
is strictly required; everything else has sane defaults (see
[`src/ConfigService.php`](src/ConfigService.php)).
```php
use Xakki\Emailer\ConfigService;
$config = new ConfigService([
'db' => [
'driver' => 'pdo_mysql',
'host' => '127.0.0.1',
'port' => 3306,
'user' => 'emailer',
'password' => 'a-strong-unique-password', // required
'dbname' => 'emailer',
],
'redis' => ['host' => '127.0.0.1', 'port' => 6379],
// Optional: enables the read-only GET /emailer/get/{key}/{secret} accessor.
'secret_key' => getenv('SECRET_EMAILER_KEY') ?: '',
]);
```
| Key | Default | Notes |
|--------------|-------------------------------|---------------------------------------------------|
| `db` | `pdo_mysql` localhost set | Doctrine DBAL connection params; `password` required |
| `redis` | `emailer-redis:6379` | Used for MX / auth caches |
| `route` | built‑in tracking routes | Phroute route → `[Controller, method]` map |
| `migration` | `src/Migration` | Doctrine Migrations config |
| `retry` | `max_attempts` 5, `first_delay` 900, `max_delay` 86400 | Backoff for temporary failures (see below); integers (integer numeric strings such as env values are cast), validated at construction |
| `sql_log` | `level` `debug`, `params` false | Logging of the SQL the repository layer runs: `level` is a lower-case PSR‑3 `LogLevel` value; `params: true` appends the bound values as JSON (`<sql> \| <json>`) and puts the column values into the INSERT / UPDATE log context (default: column names only; those lines stay at `debug`). Boolean-like strings (env `"1"`, `"true"`, `"0"`) are cast; validated at construction |
| `secret_key` | `''` (disabled) | Guards the read‑only body accessor |
> **`sql_log.params` puts bound values into your logs** — the SQL parameters
> and the row / criteria values of every INSERT / UPDATE: e‑mail addresses,
> names and other personal data. Enable it only where the log sink is allowed
> to hold that data (e.g. `'sql_log' => ['level' => 'info', 'params' => true]`
> for debugging), and keep the default elsewhere.
## Usage
### As a library
```php
use Monolog\Logger;
use Xakki\Emailer\Emailer;
use Xakki\Emailer\Model\Template;
use Xakki\Emailer\Transports\Smtp;
$emailer = new Emailer($config, new Logger('emailer'));
// 1. One-time setup: project, templates, transport, notify channel, campaign.
$project = $emailer->createProject('My project', [
Template::NAME_HOST => 'mail.example.com',
Template::NAME_ROUTE => '/emailer',
Template::NAME_LANG => 'en',
Template::NAME_URL_LOGO => __DIR__ . '/tpl/logo.png',
]);
$wrapper = $project->createTplWrapper('Base', file_get_contents('tpl/wrapper.html'));
$content = $project->createTplContent('News', file_get_contents('tpl/content.html'));
$notify = $project->createNotify('Newsletter');
$smtp = new Smtp($emailer);
$smtp->fromEmail = 'robot@example.com';
$smtp->fromName = 'Robot';
$smtp->host = 'smtp.example.com';
$smtp->port = 587;
$project->createTransport($smtp);
$campaign = $project->createCampaign('Welcome {{name}}', $wrapper, $content, $notify, []);
// 2. Queue a message for a recipient.
$mail = $emailer->getNewMail()
->setEmail('user@example.com')
->setEmailName('Jane Doe')
->setData(['name' => 'Jane']);
$hashRoute = $emailer
->getNewSender($campaign->project_id, $campaign->id)
->send($mail);
```
A runnable end‑to‑end example lives in [`example/as-vendor/init.php`](example/as-vendor/init.php).
### Processing the queue (console)
```bash
./console send [N] # send up to N new messages from the queue (default 1)
./console reSend [N] # retry up to N temporarily failed messages whose retry time has come
./console newDay # reset per-day transport counters (run daily via cron)
./console migrations migrate
```
Schedule all three via cron. **Without `reSend` nothing is ever retried**:
temporarily failed messages wait in `TEMP_ERROR` forever.
```cron
* * * * * cd /path/to/app && ./console send 100
*/15 * * * * cd /path/to/app && ./console reSend 100
0 0 * * * cd /path/to/app && ./console newDay
```
**Retries.** A temporary failure — a 4xx / greylisting / connection error, an
SMTP authentication failure, or a transient infrastructure exception (Redis
down, DB deadlock / lock wait timeout / lost connection) — puts the message in
`TEMP_ERROR` with `queue.retry_at` set on a geometric schedule: with the
defaults, 5 attempts in total, waits of 900 s, ~69 min, ~5.2 h and 24 h. After
`max_attempts` the message becomes a terminal `ERROR`. Any other failure is
terminal at once. The `reSend` interval is the practical floor for
`first_delay`.
**Transport pause.** When a transport cannot be used at all — the SMTP
connect, TLS or AUTH step fails — the rest of that transport's messages are not
attempted in the current `send` / `reSend` run (they keep their state); other
transports continue. This includes direct-MX mode (`host` = `localhost`): one
unreachable recipient MX pauses the transport for the run. A rejection later
in the dialogue (MAIL FROM / RCPT / DATA) never pauses the transport, whatever
its text.
**Delivery guarantee: at most once.** A message is claimed (`RUN`) in a short
transaction before the SMTP dialogue, which runs outside any transaction. If
the process dies mid‑send, or the result cannot be stored (DB gone), the
message stays in `RUN` instead of being sent twice. Such rows are not picked up
automatically: check rows with `status = 1` older than a few minutes against
the SMTP logs and `queue_data.last_error`, then set `status` to `0` (send
again) or `-20` (give up).
### Upgrading
Run `./console migrations migrate` **before** deploying a new version of the
code. The queue code writes `queue.retry_at` (`Version20260921140000`); without
that column every temporary failure becomes a terminal `ERROR` (its
`last_error` names `retry_at`) and `reSend` fails on every run.
### HTTP tracking endpoints
The front controller (`Emailer::dispatchRoute()`) exposes the tracking surface.
Default routes (see `ConfigService::$route`):
| Method & path | Purpose |
|------------------------------------------------|--------------------------------------|
| `GET /emailer/home/{key}` | Click‑through landing / open marker |
| `GET /emailer/goto/{key}/{url}` | Tracked outbound link redirect |
| `GET /emailer/logoimg/{key}` | Tracking pixel (logo image) |
| `GET /emailer/unsubscribe/{key}` | One‑click unsubscribe |
| `GET /emailer/subscribe/{key}` | Re‑subscribe |
| `GET /emailer/status/{key}` | Per‑message delivery status page |
| `GET /emailer/get/{key}/{secret}` | Read‑only rendered body (secret‑gated; opaque 404 when `secret_key` is unset) |
```php
echo $emailer->dispatchRoute($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
```
The JSON management API (login / dashboard / SMTP test) is documented in
[`swagger.json`](swagger.json).
## Docker
A full dev stack (PHP‑FPM, Nginx, MariaDB, Redis) is provided:
```bash
cp .env_dist .env # edit passwords
make build
make up
```
[`.env_dist`](.env_dist) holds the committed defaults for both `docker compose` and
the `Makefile` (ports, container CPU/memory caps, `PHP_VERSION`); the Makefile reads
`.env_dist` first and `.env` second, so a local `.env` wins and `make VAR=value` wins
over both. Make consumes the values verbatim — leave them unquoted.
See `make help` for the available targets.
## Development & quality
The CI matrix runs the whole tool‑chain on PHP 8.4 and 8.5. To reproduce locally
you only need PHP with the extensions listed above plus Composer:
```bash
composer install
composer test # PHPUnit
composer test-coverage # PHPUnit + HTML/text coverage (needs Xdebug or PCOV)
composer phpstan # PHPStan level 7 (src + tests)
composer cs-check # PSR-12 strict (squizlabs/php_codesniffer)
composer cs-fix # auto-fix style
```
Without a local PHP, the same gates run in a throwaway container built from
[`docker/ci/Dockerfile`](docker/ci/Dockerfile). It defaults to PHP 8.4 — the
supported floor, which is also what PHPStan and Composer resolve against — and
`PHP_VERSION` switches it to the other matrix version:
```bash
make test-ci-build # PHP 8.4 image (emailer-test:8.4)
make test-ci # composer install + PHPUnit
make test-ci-build PHP_VERSION=8.5
make test-ci PHP_VERSION=8.5
```
Current line coverage is **~70%**. Tests live in [`tests/`](tests/) and split into
pure unit tests (mocked DBAL connection) and integration tests that run against an
in‑memory SQLite database (`tests/Support/IntegrationCase.php`).
## Project layout
```
src/
Emailer.php Entry point / service locator
ConfigService.php Typed configuration
Mail.php Outgoing message value object
Sender.php Queues a message for a campaign
Controller/ HTTP + console + JSON API controllers
Model/ Active-record style domain models
Repository/ Doctrine DBAL data access
Cqrs/ Single-purpose command/query handlers
Transports/ SMTP transport (PHPMailer)
Migration/ Doctrine schema migration
Helper/, Exception/, locale/, view/
tests/ PHPUnit unit + integration suites
example/ Runnable usage examples
docker/ Local dev & CI images
```
## Contributing
Contributions are welcome — please read [CONTRIBUTING.md](CONTRIBUTING.md) first.
## License
Distributed under the **GNU General Public License v3.0 or later**. See [LICENSE](LICENSE).