Tereta/Email Module

Overview

Email delivery via pluggable transport strategies. Ships three built-in strategies (mail, smtp, log), a factory for registering and selecting them, and a validator service that protects against header injection.

The module integrates with tereta/config (per-store SMTP settings) and tereta/queue (asynchronous delivery via tereta/queue-email), but works standalone.

Requirements

  • PHP 8.4+
  • tereta/core, tereta/di

Transports

Registry nameClassPurpose
mail (default)Tereta\Email\Strategies\MailPHP mail() via the local MTA (sendmail/msmtp).
smtpTereta\Email\Strategies\SmtpDirect SMTP connection. SSL and STARTTLS support, AUTH LOGIN.
logTereta\Email\Strategies\LogSerialises an RFC822 message to <root>/var/emails/*.eml or stores it in memory (for tests).
All strategies implement Tereta\Email\Interfaces\Sender:
public function send(
    string $to,
    string $subject,
    string $body,
    array $headers = [],
    array $meta = []
): void;

Configuration

From code

use Tereta\Core\Data\Value;
use Tereta\Email\Factories\Sender as EmailFactory;

# default transport
EmailFactory::singleton()->setDefault('smtp');

# SMTP parameters
EmailFactory::singleton()->configure('smtp', Value::factory()->create()
    ->set('host', 'smtp.gmail.com')
    ->set('port', 587)
    ->set('username', '[email protected]')
    ->set('password', 'app-password')
    ->set('encryption', 'tls')   // 'tls' | 'ssl' | ''
    ->set('timeout', 30)
);

configure() accepts a Value or array<string, mixed> and is merged on top of the transport defaults (for smtp: gmail:587 + TLS, no credentials).

Via tereta/config (per-store)

If tereta/config is in use, the module auto-applies SMTP settings on the route.requestModel.created event (see src/Events/Configure.php). Expected pool keys:

mail.smtp.host
mail.smtp.port
mail.smtp.username
mail.smtp.password
mail.smtp.encryption
mail.smtp.timeout

Values are resolved for the active store ID and pushed into the factory through EmailFactory::configure('smtp', …).

Usage

Sending a single email

use Tereta\Email\Factories\Sender as EmailFactory;

EmailFactory::singleton()->create()->send(
    to:      '[email protected]',
    subject: 'Subject',
    body:    '<p>Email body</p>',
    headers: ['From' => '[email protected]', 'Reply-To' => '[email protected]']
);

create() with no argument returns the default transport. To pick a specific one — create('smtp'). Extra constructor arguments can also be supplied:

EmailFactory::singleton()->create('smtp', ['host' => 'smtp.mailgun.org', 'port' => 587]);

They are merged on top of the factory's stored configuration.

Header safety

Tereta\Email\Services\Validator automatically:

  • Validates the to address (supports the Name <email> form) — otherwise raises InvalidArgumentException.
  • Strips \r, \n, \0 from every header and the subject, blocking header injection (e.g. attempts to inject Bcc: via a tampered subject).
When you implement new strategies, always go through Validator::singleton()->validateEmail() / sanitizeHeaderValue().

log strategy for tests

use Tereta\Email\Factories\Sender as EmailFactory;
use Tereta\Email\Strategies\Log\Memory as EmailMemory;

EmailFactory::singleton()->setDefault('log');

# Writes to /var/emails/<date>_<recipient>_<rand>.eml:
EmailFactory::singleton()->create('log')->send('[email protected]', 'subj', 'body');

# In-memory storage, no filesystem touched:
EmailFactory::singleton()->configure('log', ['memorize' => true]);
EmailFactory::singleton()->create('log')->send('[email protected]', 'subj', 'body');

$captured = EmailMemory::singleton()->all(); // or ->single()

Registering a custom strategy

use Tereta\Email\Factories\Sender as EmailFactory;
use Tereta\Email\Interfaces\Sender;

class Mailgun implements Sender
{
    public function __construct(
        private readonly string $domain,
        private readonly string $apiKey,
    ) {}

    public function send(string $to, string $subject, string $body, array $headers = [], array $meta = []): void
    {
        // HTTP call to the Mailgun API
    }
}

EmailFactory::singleton()
    ->register('mailgun', Mailgun::class)
    ->configure('mailgun', ['domain' => 'mg.example.com', 'apiKey' => '...'])
    ->setDefault('mailgun');

register() verifies the class implements Sender and throws InvalidArgumentException otherwise. Constructor arguments are resolved through tereta/di, with the missing ones filled in from configure().

Logging

The module registers an email channel (<ROOT_DIRECTORY>/var/logs/email.log). It writes:

  • debug — send events ('Mail send' / 'SMTP send' / 'Log send') with to, subject, and the list of header keys.
  • error — transport failures (with to, subject, and the exception message). The exception is rethrown after logging — wrap calls in try/catch if you need fallback or retry behaviour.
Enable the relevant levels in tereta/logger:
use Tereta\Logger\Services\Channel\Config as LogChannelConfig;
LogChannelConfig::set('debug', true);
LogChannelConfig::set('error', true);

Asynchronous delivery

For queueing, install tereta/queue-email. It registers a queue strategy that publishes the payload to Kafka; on the consumer side the message hands itself back to the default sender for actual delivery. See the tereta/queue-email package documentation.

Author and License

Author: Tereta Alexander
Website: tereta.dev
License: Apache License 2.0. See LICENSE.

 www.████████╗███████╗██████╗ ███████╗████████╗ █████╗
     ╚══██╔══╝██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗
        ██║   █████╗  ██████╔╝█████╗     ██║   ███████║
        ██║   ██╔══╝  ██╔══██╗██╔══╝     ██║   ██╔══██║
        ██║   ███████╗██║  ██║███████╗   ██║   ██║  ██║
        ╚═╝   ╚══════╝╚═╝  ╚═╝╚══════╝   ╚═╝   ╚═╝  ╚═╝
                                                      .dev

Copyright (c) 2008-2026 Tereta Alexander