xakki / emailer
Self-hosted transactional & notification email service with templating, tracking, subscription management and an HTTP/console API
Requires
- php: >=8.4
- ext-fileinfo: *
- ext-json: *
- ext-pdo: *
- ext-redis: *
- doctrine/dbal: ^4.2
- doctrine/migrations: ^3.8
- monolog/monolog: ^3.5
- phpmailer/phpmailer: ^6.9
- psr/log: ^3.0
Requires (Dev)
- opsway/psr12-strict-coding-standard: ^1.1
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
- squizlabs/php_codesniffer: ^3.11
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-22 22:06:20 UTC
README
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-Unsubscribeheader. - 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
composer require xakki/emailer
Run the database migrations (against your configured connection):
./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).
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.paramsputs 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
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.
Processing the queue (console)
./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.
* * * * * 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) |
echo $emailer->dispatchRoute($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
The JSON management API (login / dashboard / SMTP test) is documented in
swagger.json.
Docker
A full dev stack (PHP‑FPM, Nginx, MariaDB, Redis) is provided:
cp .env_dist .env # edit passwords
make build
make up
.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:
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. 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:
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/ 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 first.
License
Distributed under the GNU General Public License v3.0 or later. See LICENSE.