Search by

ez-php / mail

AU9500

Transactional mail module for the ez-php framework — SMTP, log, and null drivers

Package info

github.com/ez-php/mail

pkg:composer/ez-php/mail

Statistics

Installs: 597

Dependents: 2

Suggesters: 1

Stars: 0

Open Issues: 0

2.7.1 2026-10-30 15:41 UTC

README

Transactional mail module for the ez-php framework. Delivers outgoing messages via a pluggable driver — SMTP (native PHP, no library required), Log (dev/CI), or Null (silent discard).

Installation

composer require ez-php/mail

Quick Start

Register the provider in provider/modules.php:

use EzPhp\Mail\MailServiceProvider;

$app->register(MailServiceProvider::class);

Add config values to config/mail.php:

return [
    'driver'       => getenv('MAIL_DRIVER') ?: 'null',
    'host'         => getenv('MAIL_HOST') ?: '127.0.0.1',
    'port'         => (int) (getenv('MAIL_PORT') ?: 587),
    'username'     => getenv('MAIL_USERNAME') ?: '',
    'password'     => getenv('MAIL_PASSWORD') ?: '',
    'encryption'   => getenv('MAIL_ENCRYPTION') ?: 'tls',
    'from_address' => getenv('MAIL_FROM_ADDRESS') ?: '',
    'from_name'    => getenv('MAIL_FROM_NAME') ?: '',
    'log_path'     => getenv('MAIL_LOG_PATH') ?: '',
    'timeout'      => (int) (getenv('MAIL_TIMEOUT') ?: 30), // smtp, mailgun, sendgrid, postmark, ses
];

Send a message:

use EzPhp\Mail\Mail;
use EzPhp\Mail\Mailable;

Mail::send(
    (new Mailable())
        ->to('alice@example.com', 'Alice')
        ->subject('Welcome!')
        ->text('Hello Alice, welcome aboard.')
        ->html('<p>Hello Alice, <strong>welcome aboard.</strong></p>')
);

Drivers

Driver MAIL_DRIVER Description
SMTP smtp Delivers via a real SMTP server (RFC 5321, pure PHP)
Mailgun mailgun Mailgun v3 REST API via cURL; no third-party SDK
SendGrid sendgrid SendGrid v3 Mail Send API via cURL; no third-party SDK
Postmark postmark Postmark Email API via cURL; no third-party SDK
Amazon SES ses SES v2 API via cURL with AWS Signature V4; no AWS SDK
Log log Writes a summary to a log file; safe for dev/CI
Null null Silently discards every message (default)

SMTP

MAIL_DRIVER=smtp
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=user@example.com
MAIL_PASSWORD=secret
MAIL_ENCRYPTION=tls        # tls | ssl | none
MAIL_FROM_ADDRESS=noreply@example.com
MAIL_FROM_NAME="My App"

Supports TLS via STARTTLS (port 587), implicit SSL (port 465), and plain-text (port 25). Authentication is AUTH LOGIN; omit MAIL_USERNAME to skip authentication.

Mailgun

MAIL_DRIVER=mailgun
MAILGUN_DOMAIN=mg.example.com
MAILGUN_SECRET=key-xxxxxxxxxxxxxxxxxxxx
MAILGUN_REGION=us           # us (default) or eu
MAIL_FROM_ADDRESS=noreply@example.com
MAIL_FROM_NAME="My App"

Delivers via the Mailgun HTTP API v3. US region uses api.mailgun.net; EU region uses api.eu.mailgun.net. Attachments are sent as multipart/form-data.

SendGrid

MAIL_DRIVER=sendgrid
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxx
SENDGRID_REGION=us   # or eu for accounts with EU data residency (api.eu.sendgrid.com)
MAIL_FROM_ADDRESS=noreply@example.com
MAIL_FROM_NAME="My App"

Delivers via the SendGrid v3 Mail Send API. Attachments are base64-encoded and sent inline in the JSON payload.

Postmark

MAIL_DRIVER=postmark
POSTMARK_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx   # server token
MAIL_FROM_ADDRESS=noreply@example.com
MAIL_FROM_NAME="My App"

Delivers via the Postmark Email API. Attachments are base64-encoded and sent inline in the JSON payload.

Amazon SES

MAIL_DRIVER=ses
SES_ACCESS_KEY_ID=AKIA...
SES_SECRET_ACCESS_KEY=...
SES_REGION=eu-west-1
MAIL_FROM_ADDRESS=noreply@example.com
MAIL_FROM_NAME="My App"

Delivers via the SES v2 API as a raw MIME message, signed with AWS Signature V4. The key pair needs ses:SendEmail. Temporary credentials (session tokens) are not supported.

Log

MAIL_DRIVER=log
MAIL_LOG_PATH=/var/www/html/storage/logs/mail.log

When MAIL_LOG_PATH is empty, messages are written via error_log().

Mailable

Mailable is a fluent builder that can be used inline or extended per-message type:

// Inline
$mail = (new Mailable())
    ->to('bob@example.com', 'Bob')
    ->from('noreply@example.com', 'My App')   // overrides driver default
    ->subject('Your Invoice')
    ->text('Please find your invoice attached.')
    ->html('<p>Please find your invoice <strong>attached</strong>.</p>')
    ->attach('/path/to/invoice.pdf', 'Invoice-2026-01.pdf');

// Extended
class InvoiceMail extends Mailable
{
    public function __construct(User $user, string $invoicePath)
    {
        $this->to($user->email, $user->name)
             ->subject('Your Invoice')
             ->text('Please find your invoice attached.')
             ->attach($invoicePath);
    }
}

// Generated content — no temp file needed (rendered PDF, ez-php/storage file, ...)
$mail->attachData($pdfBytes, 'Invoice-2026-01.pdf', 'application/pdf');

Methods

Method Description
to(string $address, string $name = '') Set recipient
from(string $address, string $name = '') Override sender (uses driver default when not called)
subject(string $subject) Set subject line
text(string $body) Set plain-text body
html(string $body) Set HTML body
view(string $name, array $data = []) Render the HTML body from a template at send time (needs a bound MailViewInterface, see below)
attach(string $path, string $name = '') Add file attachment
attachData(string $content, string $name, string $mimeType = 'application/octet-stream') Add an in-memory attachment (no temp file)

Combining text() and html() produces a multipart/alternative message. Adding attachments wraps the content in multipart/mixed.

Template bodies with view()

view() stores a template name and data; the HTML is rendered when the mail is built. Rendering goes through MailViewInterface (one method, render(string $template, array $data = []): string), so any template engine fits. Bind an implementation in a provider registered before MailServiceProvider — its boot() hands the bound renderer to Mailable::setViewRenderer(). With ez-php/view, a thin adapter over ViewEngine::render() is enough:

use EzPhp\Mail\MailViewInterface;
use EzPhp\View\ViewEngine;

$this->app->bind(MailViewInterface::class, fn ($app) => new class ($app->make(ViewEngine::class)) implements MailViewInterface {
    public function __construct(private readonly ViewEngine $views)
    {
    }

    public function render(string $template, array $data = []): string
    {
        return $this->views->render($template, $data);
    }
});

$mail = (new Mailable())->to('alice@example.com')->subject('Welcome')->view('emails.welcome', ['name' => 'Alice']);

view() takes precedence over html(). Without a bound renderer, building the mail throws MailException. Tests that call Mailable::setViewRenderer() themselves reset it with Mailable::resetViewRenderer().

Static Facade

Mail::send(Mailable $mailable) delegates to the driver bound by MailServiceProvider.

In tests, inject a spy driver directly:

use EzPhp\Mail\Mail;
use EzPhp\Mail\Mailable;
use EzPhp\Mail\MailerInterface;

// Arrange
$spy = new class implements MailerInterface {
    public array $sent = [];
    public function send(Mailable $mailable): void { $this->sent[] = $mailable; }
};
Mail::setMailer($spy);

// Act
Mail::send((new Mailable())->to('a@b.com')->subject('Hi')->text('body'));

// Assert
assert(count($spy->sent) === 1);

// Teardown
Mail::resetMailer();

MIME Support

MimeBuilder constructs RFC 2822 / MIME messages internally. It handles:

  • text/plain (quoted-printable)
  • text/html (quoted-printable)
  • multipart/alternative (text + HTML)
  • multipart/mixed (any of the above + attachments)
  • RFC 2047 encoding for non-ASCII subject lines and display names
  • Base64-encoded attachments with MIME type detection via mime_content_type()

Generating a Mailable

php ez make:mailable WelcomeMail

writes app/Mail/WelcomeMail.php — a Mailable subclass that configures recipient, subject and body in its constructor; send it with Mail::send(new WelcomeMail($user->email, $user->name)).

Queue Integration

Mail delivery is synchronous by default — Mail::send() blocks until the driver finishes. To dispatch mail asynchronously, push a Job\SendMailableJob (requires ez-php/queue to be installed — it's a soft dependency, declared only in require-dev here):

use EzPhp\Mail\Job\SendMailableJob;
use EzPhp\Mail\Mailable;

$queue->push(new SendMailableJob(
    (new Mailable())
        ->to($user->email, $user->name)
        ->subject('Welcome!')
        ->text('Hello, welcome aboard.')
));

The Mailable is serialized with the job. Mail::send() inside handle() uses whatever driver is registered in the worker process — typically SmtpDriver in production and NullDriver or LogDriver in development.

Need something beyond "deliver this one Mailable" — custom retry policy, batching, etc.? Write your own Job subclass instead, following the same one-line handle() pattern.

Local Development with Mailpit

Mailpit is a local SMTP mail catcher with a web UI. All outgoing mail is captured and displayed without being delivered to real recipients.

1 — Add Mailpit to docker-compose.yml

services:
  mailpit:
    image: axllent/mailpit
    container_name: my-app-mailpit
    ports:
      - "1025:1025"   # SMTP
      - "8025:8025"   # Web UI
    environment:
      MP_SMTP_AUTH_ACCEPT_ANY: 1
      MP_SMTP_AUTH_ALLOW_INSECURE: 1

2 — Configure the SMTP driver to point at Mailpit

MAIL_DRIVER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_ENCRYPTION=none
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM_ADDRESS=dev@example.com
MAIL_FROM_NAME="My App (dev)"

3 — Open the Mailpit web UI

Navigate to http://localhost:8025 in your browser. Every message sent via Mail::send() appears here instantly.

4 — SMTP integration tests

To run the Mailpit smoke tests in ez-php/mail itself:

MAILPIT_HOST=127.0.0.1 MAILPIT_SMTP_PORT=1025 MAILPIT_API_PORT=8025 \
  vendor/bin/phpunit --group mailpit

The three Mailpit tests are skipped automatically when MAILPIT_HOST is not set.