Ctrl K
ComunidadGuías…

README.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Emailer

[![CI](https://github.com/Xakki/emailer/actions/workflows/ci.yml/badge.svg)](https://github.com/Xakki/emailer/actions/workflows/ci.yml)
[![PHP](https://img.shields.io/badge/php-8.4%20%7C%208.5-777bb4.svg)](https://www.php.net/)
[![License](https://img.shields.io/badge/license-GPL--3.0--or--later-blue.svg)](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).