Search by

xakki / emailer

Xakki

Self-hosted transactional & notification email service with templating, tracking, subscription management and an HTTP/console API

Package info

github.com/Xakki/emailer

pkg:composer/xakki/emailer

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 5

Open Issues: 0

dev-master 2026-09-22 22:05 UTC

This package is auto-updated.

Last update: 2026-09-22 22:06:20 UTC


README

CI PHP 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

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.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

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.