Skip to content

Pramnos Framework - Email System Guide

The Pramnos Framework includes a comprehensive email system built on top of Symfony Mailer that provides a clean, flexible API for sending emails with advanced features like tracking, templates, and multiple transport options.

Table of Contents

  1. Overview
  2. Basic Usage
  3. Configuration
  4. Advanced Features
  5. Tracking: opens and clicks
  6. A message to one account
  7. A message to many accounts
  8. What kinds of mail this application sends
  9. Unsubscribing, and what Gmail requires
  10. The line the inbox shows beside the subject
  11. Dark mode
  12. Accessibility in the message
  13. What DNS says
  14. Bounces, and addresses mail does not go to
  15. The outbox
  16. Retention
  17. SMTP Configuration
  18. Error Handling
  19. Best Practices
  20. API Reference

Overview

The Email system (\Pramnos\Email\Email) is a powerful wrapper around Symfony Mailer that provides:

  • Simple API: Fluent interface for building and sending emails
  • SMTP transport: the installation's smtp_* settings, or per message another SMTP server (via()) or any Symfony Mailer transport (withTransport())
  • Email Tracking: opt-in open and click tracking for list mail, recorded in the database
  • Attachment Support: File attachments with validation
  • Template Integration: Works seamlessly with the theme system
  • Error Handling: Comprehensive error reporting and debugging
  • Priority Support: Set email priority levels
  • Advanced Headers: Custom headers, read receipts, unsubscribe links

Basic Usage

Sending a Simple Email

use Pramnos\Email\Email;

// Create new email instance
$email = new Email();

// Set email properties and send
$email->setSubject('Welcome to our service')
      ->setBody('<h1>Welcome!</h1><p>Thank you for joining us.</p>')
      ->setTo('user@example.com')
      ->setFrom('noreply@yoursite.com')
      ->send();

Using the Static Method

// Quick static method for simple emails
$result = Email::sendMail(
    'Subject Line',                    // Subject
    '<p>HTML email content</p>',       // Body (HTML)
    'recipient@example.com',           // To
    'sender@yoursite.com',             // From
    '',                                // Attachment path (optional)
    false,                             // Batch mode (optional)
    'reply@yoursite.com'               // Reply-to (optional)
);

if ($result['success']) {
    echo "Email sent successfully!";
} else {
    echo "Error: " . $result['error'];
}

Multiple Recipients

$email = new Email();
$email->setSubject('Newsletter')
      ->setBody('<h1>Monthly Newsletter</h1>')
      ->setTo([
          'user1@example.com' => 'John Doe',
          'user2@example.com' => 'Jane Smith',
          'user3@example.com'  // Email without name
      ])
      ->setCc('manager@example.com')
      ->setBcc('archive@example.com')
      ->send();

Configuration

SMTP Settings

Configure SMTP settings in your application settings:

use Pramnos\Application\Settings;

// Basic SMTP configuration
Settings::setSetting('smtp_host', 'smtp.yourprovider.com');
Settings::setSetting('smtp_user', 'your-username');
Settings::setSetting('smtp_pass', 'your-password');
Settings::setSetting('smtp_port', 587);
Settings::setSetting('smtp_tls', 'yes');

// Default from address
Settings::setSetting('admin_mail', 'noreply@yoursite.com');
Settings::setSetting('sitename', 'Your Website Name');

smtp_pass is encrypted at rest, and the /admin/Settings screen never prints it back: the field renders empty, submitting it empty means "keep what is stored", and the placeholder is what tells you whether there is one. To clear it, write an empty value through Settings::setSetting().

With no smtp_host every send fails, with getLastError() reading SMTP not configured: smtp_host is empty. Email::isConfigured() asks the same question before a send.

Settings::setSetting('admin_replymail', 'support@yoursite.com');

AWS SES Configuration

For Amazon SES (port 587 with STARTTLS):

Settings::setSetting('smtp_host', 'email-smtp.us-west-2.amazonaws.com');
Settings::setSetting('smtp_user', 'your-ses-access-key');
Settings::setSetting('smtp_pass', 'your-ses-secret-key');
Settings::setSetting('smtp_port', 587);
Settings::setSetting('smtp_tls', 'yes');

Advanced Features

Email with Attachments

$email = new Email();
$email->setSubject('Document Attached')
      ->setBody('<p>Please find the attached document.</p>')
      ->setTo('user@example.com')
      ->setFrom('sender@example.com');

// Set attachment path
$email->attach = '/path/to/document.pdf';

$email->send();

Setting Email Priority

$email = new Email();
$email->priority = 1; // 1 = highest, 5 = lowest (default: 3)
$email->setSubject('Urgent: Action Required')
      ->setBody('<p>This is an urgent message.</p>')
      ->setTo('user@example.com')
      ->send();

Custom Headers

$email = new Email();
$email->addHeader('X-Campaign-ID', 'newsletter-2024-01')
      ->addHeader('X-Mailer', 'Pramnos Framework');

// Built-in header support
$email->organization = 'Your Company Name';
$email->unsubscribe = 'mailto:unsubscribe@yoursite.com';
$email->abuse = 'abuse@yoursite.com';
$email->returnPath = 'bounces@yoursite.com';

$email->send();

Read Receipts

$email = new Email();
$email->sendReceipt = true;
$email->setSubject('Important Document')
      ->setBody('<p>Please confirm you have received this.</p>')
      ->setTo('user@example.com')
      ->send();

Message templates, and the screen that edits them

The messaging feature ships mailtemplates — a table, a model, and a lookup by (category, language, type). One notification is several rows: the same category and channel, one row per language.

$template = (new \Pramnos\Messaging\MailTemplate($controller))
    ->findByKey('auth.passwordreset', 'el', \Pramnos\Messaging\MailTemplate::TYPE_EMAIL);

/admin/MailTemplates is where an operator edits them, so changing the wording of a password-reset email is a screen, not a code change.

Three things the screen does, each because leaving it out makes the screen decorative:

  • It lists the placeholders, read from the template's own body and subject — a documented list goes stale the first time an application adds one, and an editor that shows none is a form where a typo produces a mail with a literal {nmae} in it. CSS braces are not mistaken for placeholders.
  • It groups the language variants, so the list answers "is the reset email translated into Greek" instead of showing eighty flat rows.
  • It sends a test, because the only way to know a template renders is to render it. Placeholders arrive as [name] — visible where each one lands, without invented data that would hide a missing one.

The body is stored as written: an email template is markup, and a screen that sanitised it would make the feature useless. It is escaped where it is displayed — into a <textarea> and a <pre> — which is the correct half to do it in.

The category and the title are stripped, and the asymmetry is deliberate: they are labels, printed where markup is a defect rather than the point.

Testing a test send. MailTemplatesController::mailer() is a protected seam returning the Email a test send goes through, so the substitution and the wrapper lookup can be asserted without an SMTP server. Override it and read what was handed over:

protected function mailer(): \Pramnos\Email\Email
{
    return new class extends \Pramnos\Email\Email {
        public function send() { return true; }
        // …record setBody()/setTemplate()/setTo()
    };
}

Without overriding it, a test can reach only test()'s refusals, not the two things the action exists to prove.

Which language a message is written in

Notifier::sendNow() renders every notification in the recipient's language — the language property of the notifiable, which on a User is users.language. A notifiable without one (a PlainAddress, an account that never chose) is sent in whatever language the installation is currently using.

The language of a request belongs to whoever made it: an operator resetting a password from an English administration screen must still send a Greek account a Greek mail, and a queue worker has no request language at all.

Mail composed outside the notification system asks for the same thing itself — see Language::using() in the Internationalization guide. The framework's own auth mail (codes, sign-in alerts, security changes, the reset link) is all translatable: the keys are the English sentences, so an application supplies its language file and nothing else.

The wrapper a message is sent in

Bodies are fragments — a paragraph, a code, a link — and every application wants the same shell around all of them: its logo, its colours, a footer with a company name in it. That shell is a wrapper, named rather than derived:

// Every message, from the settings
Settings::setSetting('emailtheme', 'default');

// Or one message
(new Email())->setBody('<p>Your code is 123456</p>')->setTemplate('branded')->send();

mailtemplates.emailtemplate is the per-template version of the same choice, and the test-send on /admin/MailTemplates uses it — so what arrives in a test is what a recipient would get.

Off until it is named. emailtheme is empty on an existing installation and an empty name wraps nothing: bodies go out exactly as they did before. That matters more than it sounds — an application whose bodies are already complete documents would otherwise get a second <html> inside the first on an upgrade.

null, '' and a name are three different things. null (the default) takes the installation's setting; a name overrides it; and '' sends this message bare, which is the only way to send an unwrapped one from an installation that wraps everything — a body that is already a whole document, or one meant to be parsed rather than read.

Where a wrapper lives

{name}.html.php, in the first of these that has it:

Path For
app/emails/ the application's own
emails/ at the project root an older layout
the framework's bundled copy so default resolves with nothing published

Copy the bundled default into app/emails/ and edit it there: the application's file of the same name wins, so the copy is the customisation and the bundled one stays the fallback.

Not per theme, deliberately. A theme is a stylesheet and an email cannot use one — HTML mail is nested tables and inline attributes, because Outlook renders with Word's engine and Gmail strips <style> from anything forwarded. An application that wants two looks names two wrappers.

What the file receives

<?php /* app/emails/branded.html.php */ ?>
<!DOCTYPE html>
<html><body>
    <h1><?php echo htmlspecialchars($sitename); ?></h1>
    <?php echo $content; ?>
    <footer><?php echo htmlspecialchars($sitename . ' · ' . $year); ?></footer>
</body></html>

$content is the body, already HTML. $subject, $sitename, $siteurl and $year come with it, so a wrapper needs no arguments; anything else passed to EmailTheme::wrap() is in scope under its own key. $content is the one variable a wrapper cannot replace.

It fails open, and the name is not a path

A wrapper that is missing, or that raises while rendering, logs to the email log and the message is sent unwrapped. A mail whose shell is broken still has to be delivered: the code in it is what somebody is waiting for, and a missing footer is not a reason to withhold it. That is also what makes a typo in a settings field cost one unbranded email rather than every email.

The name is checked against [A-Za-z0-9_-] before it reaches a path, because it arrives from a column an administrator edits. Anything with a separator in it is refused rather than sanitised — there is no correct number of .. segments to strip, and a name with a slash in it was never a wrapper name.

The inbox a message lands in

/messages is where an account reads what was sent to it: a list, one message per page, and reading it marks it read. Not a mail client — no compose, no reply, no folders. The screens it does not have are screens nobody has to maintain.

// src/Controllers/Messages.php — what `pramnos init` writes for an application
class Messages extends \Pramnos\Messaging\Controllers\MessagesController {}

MessagesController::unreadCount($userId) is the number for a badge; it answers zero rather than throwing, because a badge is not worth an exception on an unrelated page.

This is where an internal message is read. MassMessageDispatcher writes a row per recipient to messages when a broadcast goes out as an internal message, and Message::countUnread() counts them; /messages is the screen that displays them. Without it in a project, the progress screen reports every recipient delivered and nobody can read a word.

messages.type carries the state, and it is overloaded — the same column distinguishes an inbox item from a sent one, an archived one and a deleted one. The listing therefore names the states it wants (MessagesController::INBOX_TYPES) rather than excluding the ones it does not: a state added later must not appear in somebody's inbox because a NOT IN list was not updated.

Reading a message is what marks it read, and a notification keeps its own read state (TYPE_NOTIFICATION_READ) rather than collapsing into TYPE_READ — the pair exists so a screen can tell something we told you from something somebody sent you, and collapsing it would lose that on first read.

Writing to messages yourself

Two things bite, and both differ between backends, so test on each one you support.

attachmenttext has no default. It is TEXT NOT NULL, so an insert that omits it fails — on PostgreSQL always, and on MySQL under strict mode. Pass '' explicitly, as MassMessageDispatcher does:

$db->queryBuilder()->table('#PREFIX#messages')->insert([
    // …
    'attachmenttext' => '',   // TEXT NOT NULL with nothing to fall back on
]);

A failed query is not always an exception. MySQL throws; PostgreSQL answers false. So

try {
    $result = $qb->…->get();
} catch (\Throwable $e) {
    return [];          // never reached on PostgreSQL
}

while (($row = $result->fetch()) !== null) {   // fatal: fetch() on false

is correct on one engine and a crash on the other. Guard the result as well as the call:

if (!$result) {
    return [];
}

Worth stating as a rule rather than a footnote: a try/catch around a query is only half the handling. Without the guard, the same lines report an error politely on MySQL and crash on PostgreSQL.

A message to one account

/admin/Users/notify/<id> — the Send screen on an account. An operator frequently needs to say something to one person ("your export is ready", "we reset your second factor"), and the alternative is their own mail client, which leaves no record on the account and uses whatever address they happen to type.

Three channels, ticked independently:

Email to the address on the account
Notification a row in notifications — readable next time they sign in
Push on the device, with the browser closed (guide)

A channel this account cannot receive is disabled, with its reason. No usable address, no VAPID pair, no browser subscribed — each says which. An operator who presses Send and is told "sent" is entitled to believe it, and a channel that silently delivers nothing is invisible from the outside: nothing errors, the message simply never arrives. "No browser has subscribed" and "this installation has no key pair" also need different people to fix them.

The body is written in the framework's own editor (below) and sent as HTML reduced by Pramnos\Html\SafeHtml to paragraphs, emphasis, headings, lists, quotes, links (http, https, mailto) and https images — every other element and attribute is removed. Text posted without markup is escaped and keeps its line breaks. A push notification gets the text of it.

Every mail option this guide describes is on that screen, because this is where they get tried: a wrapper nobody has rendered and a Gmail action nobody has seen arrive are both things you find out about from a real message, not from a test.

Notification\Message — the notification with no event

Every other notification is a class per event — InvoicePaid, NewSignIn — which is the right shape when the event is known in advance. This is for when it is not: an operator writing a sentence, an administration screen, a test send. There is no event to name, so there is no class to write.

use Pramnos\Notification\Message;

$user->notify(
    (new Message('Your export is ready', '<p>It is on your downloads page.</p>'))
        ->to('mail', 'database', 'push')
        ->link(sURL . 'account/downloads')
        ->list('exports')          // → a working unsubscribe, and opt-outs are respected
        ->template('receipt')      // → '' for no wrapper, null for the default
        ->track()                  // → asked for; Tracking still decides
        ->action(Actions::view('Open it', sURL . 'account/downloads'))
);

Each channel gets a shape it can use. The push body is stripped of markup and flattened to one line, because a push is two lines on a lock screen — handed HTML it shows the tags, handed a paragraph the operating system truncates it at a point nobody chose.

Mail is one channel of several here. The Notifications guide covers the dispatcher itself — the contracts, the other channels, routing per recipient, and why a channel silently delivered nothing.

The mail options are declarations, not calls

MailChannel reads four optional methods off a notification — unsubscribeList(), mailTemplate(), trackingRequested(), mailStructuredData(). A notification that declares none of them is transactional and gets none of it: no unsubscribe link, no suppression, no pixel, no ld+json.

That default is the important half. A password reset must arrive even for somebody who unsubscribed from everything, an unsubscribe link on it teaches people the link does nothing, and a tracking pixel on it tracks somebody who never agreed to be tracked. Any notification can declare these — the capability is not confined to code that builds an Email by hand.

class WeeklyDigest implements NotificationInterface
{
    public function unsubscribeList(): string { return 'digest'; }
    public function trackingRequested(): bool { return true; }
    public function toMail(mixed $notifiable): array { … }
}

The editor a message is written in

The body of a mass message is written in whichever editor the application chose; the message to one account always uses the framework's own.

// app/app.php
'messaging' => [
    'editor'  => 'builtin',   // builtin (default) | rich | tinymce | none
    'rich'    => [            // only for 'rich'; both optional
        'upload' => adminUrl('Media/upload'),   // pictures POSTed here as `file`; answers {"url": …}
        'links'  => adminUrl('search'),         // the link panel searches here (?q=)
        'block_toolbar' => true,                // a toolbar over the block at the cursor; off by default
    ],
    'tinymce' => [            // only for 'tinymce'
        'src'         => sURL . 'assets/vendor/tinymce/7.2.0/tinymce.min.js',  // default: the jsDelivr CDN
        'license_key' => 'gpl',                                              // TinyMCE's requirement
    ],
],
Mode What it is
builtin www/assets/js/pf-editor.js: bold, italic, two heading levels, lists, a link, an image by its https address, clear formatting, and the HTML itself. Pasting inserts text, not the pasted page's styles — the commonest reason a message looks broken in Outlook. No dependency, nothing a Content-Security-Policy has to allow.
rich www/assets/js/pf-rich-editor.js, TipTap bundled: pictures with alt text and captions, uploaded by drop, paste or choice, a link panel that searches the site, blocks moved with Alt+↑/↓, embeds, and plugins of the site's own. No CDN, MIT licensed. See the Rich Text Editor guide.
tinymce TinyMCE, loaded from src and initialised with license_key. Where it comes from and under which licence are the application's decisions; a CDN source must be allowed by the CSP's script-src.
none The textarea, as markup.

A form marks its field and prints the scripts. The field may sit inside its <label>: the editor is drawn after the label, never inside it, and a click on the label's words puts the cursor in the text.

<textarea name="body" data-pf-editor="<?php echo \Pramnos\Html\MessageEditor::mode(); ?>"></textarea>
<?php echo \Pramnos\Html\MessageEditor::scripts(\Pramnos\Html\MessageEditor::mode()); ?>

Headings, lists and quotes in the area are styled by a small scoped stylesheet scripts() prints, so a theme whose CSS reset flattens them (Tailwind's preflight) still shows what the markup is. On the mass-message form the editor gives way to the bare field when the channel is push — a notification carries text — and comes back for email and an internal message, which both keep markup; window.PfEditor.plain(textarea, bool) does the same for any form.

Composing a message: saved messages, placeholders, AI, the signature

Above the message forms — "Message one user" and the mass-message composer — sits a toolbar, Html\MessageComposer, for any form with a message body:

<form method="post" action="…" data-pf-composer data-pf-composer-url="<?= adminUrl('Composer') ?>"
      data-pf-composer-scope="user-message"
      data-pf-editor-fields='<?= \Pramnos\Html\MessageComposer::fieldsJson() ?>'>
    <?= \Pramnos\Html\MessageComposer::toolbar() ?>
    <input name="subject" data-pf-composer-subject>
    <textarea name="message" data-pf-editor="<?= \Pramnos\Html\MessageEditor::mode() ?>" data-pf-composer-body></textarea>
</form>

It works with whichever editor the body is in — builtin, rich, TinyMCE or none — through pf-utils.js.

  • Saved messages. A list fills the subject and the body — shown once there is something in it; "Save this message" opens a name field under the toolbar, with "For everybody" where the account may share: the messages.share ability where the application defines it, the administration floor where it does not. The same name in the same scope is replaced; "Forget" asks by a second press. They live in savedmessages (migration create_savedmessages_table), apart from the mail templates, which are the application's notifications. Placeholders stay as written.
  • Fields. data-pf-editor-fields — [{"token": "{firstname}", "label": "First name"}] — becomes one "Recipient details ▾" dropdown (Greek «Στοιχεία ▾»; the key is specific so an application's own "Fields" translation does not rename it); a choice goes in at the cursor of whichever of the subject and the body had the focus last, and the dropdown closes — as on its own toggle or Escape. An entry with a value inserts the value instead of the token: a message to one person reads "Cathy", not a placeholder. MessageComposer::fieldsJson($fields, $values) builds the list — fieldsJson(null, MessageComposer::valuesFor($user)) gives the framework's own fields with one account's values, which is what "Message one user" passes; the mass-message form passes tokens. A token is never filled in the page: the framework's forms fill {firstname}, {lastname}, {full_name}, {username} and {email} when they send, for each recipient (MessageComposer::fill() with valuesFor($user)); an application with its own fields fills its own. A value put in from the menu is remembered with its token, and saving puts the token back: "Γεια σου Cathy" is saved as "Γεια σου {firstname}", so the saved message greets each reader by their own name. Loading a saved message into a one-person form fills its tokens with that recipient's values, remembered the same way. What is sent is the text as it reads. Matching is by value, in text and never inside markup — the same word typed by hand next to one put in from the menu goes back to the token too.
  • Write with AI, where a language model is configured, opens a panel under the toolbar: what the message should say — or nothing, to improve what is written — and the model answers a subject and a body, keeping every placeholder, in data-pf-composer-language when the form gives one. Undo puts back the subject and the body it replaced. The call is logged and costed as every call is (purpose compose); the body comes back cleaned to what a message may carry.
  • The signature is the account's own (usersettings mail.signature), edited at Composer/signature in the application's message editor. "With my signature" adds it under the body when the form is sent, once.

Nothing in the composer opens a browser dialog: questions and answers are in the panels and in the toolbar's status line. toolbar(['saved' => false, 'ai' => false, 'signature' => false]) leaves a part out, and toolbar(['fields' => 'buttons']) draws the fields as a row of buttons instead of the dropdown. Every control carries a pf-composer-* hook beside the button and field classes of the project's scaffold_theme; each theme's components.css lays them out. The endpoints are the Composer controller's: saved?scope=, and the POSTs save, forget and write, each with the session's token.

Without JavaScript the textarea is simply there. pf-editor.js ships with the other pf-*.js files: init writes it and project:resync --js brings it to a project made before it existed. window.PfEditor.enhance(textarea) enhances a field added after the page loaded.

A message to many accounts

/admin/MassMessages composes, sends and reports on a message stored in massmessages, with one row per recipient in massmessagerecipients. Behind it are two classes:

$audience = (new MassMessageAudience())->resolve(['usertype_min' => 0]);
(new MassMessageDispatcher())->queue($messageId, $audience);   // once, in the request
(new MassMessageDispatcher())->dispatch(100);                  // over and over, on a timer

Queueing and delivering are separate because they fail differently

queue() writes one recipient row per account and returns. dispatch() — driven by messages:dispatch, scheduled every five minutes — takes pending recipients in batches and marks each one as it is attempted.

A send of four thousand emails inside a POST is a request that times out halfway, leaving an operator with no idea how far it got and a page offering to send again. Here the answer to "how far did it get" is a row count, and the answer to "is it safe to run again" is yes.

Queueing is the step that must not repeat. A message that already has recipients is refused rather than queued a second time: everything else on this screen is recoverable, and that one reaches every person on the list.

What each channel means

Type Delivery Failure
TYPE_EMAIL one email per recipient, in the recipient's language and this installation's wrapper the mailer refused it
TYPE_MESSAGE a row in messages — the account's own inbox the write failed
TYPE_PUSH one web push per subscribed browser (guide) no browser is subscribed

An account with no subscription is a failure, not a skip. It is the ordinary case — most accounts have never granted notification permission — and recording it as delivered would leave an operator reading "4,812 delivered" about a message that reached forty people. The same is true of an installation with no VAPID pair or no encryption library: it is checked before the send rather than left to the channel, which logs and returns, so every recipient of a message that was never encrypted would otherwise be recorded as delivered.

Somebody who unsubscribed is the exception, and is recorded as delivered: the recipient row is a record of what happened to a person, and "we honoured their request" is not a failure to retry on the next run.

The audience is resolved once

Criteria are stored on the message (request, as JSON); the list they meant is stored in massmessagerecipients. Re-resolving at delivery time would silently include accounts created after somebody approved the send and drop the ones deleted since — so the recipients would stop matching what was approved.

Criterion What it selects
usertype_min / usertype_max a band. A ceiling matters: with a floor alone, "everybody below staff" can only be written as "everybody", which also reaches the operators
validated_only, active_only on by default — a validated address is the difference between a send and a bounce, and an inactive account is one somebody switched off
language the account's language. A message in Greek sent to everybody also reaches the people who set their account to English, and they cannot read it
twofactor with or without. Fails closed: on an installation with no authserver, "holding a second factor" is nobody, not everybody
last_login_after / last_login_before the active and the dormant audience. An account that never signed in has lastlogin = 0, so it is in the dormant one — which is the correct answer to that question
groups in any of the chosen groups. Any, not all: "members and volunteers" is a message to both, and the intersection is a smaller audience somebody can name directly
organizations in any of them. Read from authserver.user_organizations by userid — or the table and organization column named by authserver_organization_table / authserver_organization_column, the same settings the organizations screen reads. The membership table is the authserver feature's, so an installation without it matches nobody rather than raising
only_ids these accounts and no others — «send this to these three people», the commonest thing anybody wants from this screen
exclude_ids everything the rest matched, minus these
exclude_optouts a list name. They are skipped at delivery either way; naming it here is what makes the count honest

Or pick it with segments, groups and a filter. The compose form also carries the audience picker (Audience guide): people in any of some segments and groups, except others, narrowed by a filter for this send, in a consent list. When anything is picked there, the criteria are ['audience' => <picker state>] and the table above is not used; MassMessageAudience resolves it with Picker::final(), describes it in words on the message's page, and a state that no longer checks reaches nobody.

A filter that matches nobody is an empty audience, not everybody. The dangerous direction: a group filter falling back to "no filter" is how a message meant for eleven volunteers reaches every account on the installation, and the operator finds out from the replies.

only_ids does not override the other criteria. An operator pasting a list from a spreadsheet has not checked which of those accounts is inactive, unvalidated or unsubscribed, and a screen that sent to them anyway would be treating a paste as an override of every check on the page. The preview is where they see which ones dropped out. Ids are read however somebody has them — commas, newlines or spaces — because all three are the same intention.

Looking before sending

$preview = (new MassMessageAudience())->preview($criteria);
// ['total' => 4812, 'sample' => [ … 25 rows … ], 'truncated' => 4787]

What the criteria mean — how many people, and which ones — has to be visible before the send, because a send to the wrong band of accounts is not something anybody can take back.

Preview this audience on the compose form posts the same fields to MassMessages/preview, which resolves them and renders the form again with the answer on it. Nothing is written and nothing is sent, so an operator can try a filter, look, and change it. It is a form post rather than a fetch on purpose: it has to work identically in three themes and with no JavaScript.

The sample is a window, not the audience — forty thousand rows are not a thing to render — and it says how many it is not showing, because a list that silently stopped at twenty-five would read as an audience of twenty-five.

The last of the criteria is worth dwelling on. The compose screen counts the audience before anybody presses send, because a count is the one number that changes an operator's mind — and a count that includes nine hundred people who unsubscribed changes it in the wrong direction. An opt-out from all counts for every list: somebody who pressed "stop sending me anything" is not asking to stay on the announcements list.

An application with its own idea of an audience hands queue() its own list of ids instead.

Two traps in reading a compose form

Both are in criteriaFrom(), both are asserted, and both mail the wrong people when got wrong.

A false boolean is not an empty value. array_filter cannot tell the operator unticked this from the operator said nothing, so the two booleans are written after it:

$criteria = array_filter([...], fn ($v) => $v !== '' && $v !== 0);

$criteria['validated_only'] = (bool) $request->get('validated_only', 0, 'post', 'int');
$criteria['active_only']    = (bool) $request->get('active_only', 0, 'post', 'int');

Dropped, validated_only reverts to its default — which excludes accounts the operator chose to include, and makes the count on the screen disagree with the send.

An empty template is a decision. "No wrapper for this campaign" is a thing somebody chooses and looks exactly like an empty form field, so the form posts __default__ for said nothing and '' for chose none. Only the second is stored.

The audit record follows the same rule: no options chosen means no options key, because an empty object in the record reads as a decision, and this record is what somebody months later reads to answer "who was this aimed at, and how".

Reading it back

request is a JSON column on MySQL and text on PostgreSQL, and MySQL reformats the document — its own key order, spaces after colons. So compare the decoded value, never the serialisation: a test that matched '"language":"el"' passes on exactly one backend.

criteriaOf() decodes defensively for the same reason it exists: an older row, or one written by something else, is not a reason to fail rendering the screen — the screen is how somebody finds out what happened.

The send options travel with the criteria

A campaign can also carry a wrapper, an unsubscribe list, open/click tracking and a Gmail action — the same options the single-account screen offers, and they matter more here: a wrapper wrong on one message is a mistake, and wrong on forty thousand is the send.

They are stored under options inside the same request JSON, because they are part of the same decision and a column per option is a migration every time somebody adds one. A row written before they existed has no options key and reads as none.

Tracking gets its own id per recipient. One id for the campaign would count the first open out of forty thousand people as an open, and nothing after it.

The compose screen counts the audience before anybody presses send, because a count is the one number that changes an operator's mind, and it is exactly the number nobody has when the send is a loop somebody wrote in a controller.

Sending to a mailing list

The compose form's Or send to a mailing list replaces the account criteria with an opt-in list's confirmed subscribers — with or without an account (criteria key mailing_list). The preview counts and samples them the same way.

  • A subscriber who is also an account is one recipient, however many addresses it subscribed under; a pending address is nobody's recipient.
  • Recipients are queued by address (massmessagerecipients.email, with userid 0 when there is no account) and each is written to in the language it subscribed in.
  • Each message goes out as the list's MailType, so the list's unsubscribe header and link come with it; a subscriber who left between queueing and sending is skipped and counted as done.
  • A list can only be sent an email — a subscriber with no account has no inbox here and no push subscription. Saving a message addressed to a list makes it one, sets its unsubscribe list to the list itself, and stores the list as the whole audience: the account criteria are not kept beside it. A message stored otherwise is refused at send.
  • On the form, choosing a list hides the account criteria and the opt-out filter, fixes the channel to email and fills in the unsubscribe list, read-only. Track opens and clicks starts ticked on a new message wherever tracking is switched on.

What kinds of mail this application sends

A kind — «password reset», «weekly digest», «sign-in alert» — is the thing a person means when they say they get too many emails from you, and every feature around mail needs a name for it: the unsubscribe list, the audit log's name for the message, and the page that shows somebody the mail they can turn off. A MailType is that name, declared once.

use Pramnos\Email\MailType;
use Pramnos\Email\MailTypes;

// Once, at boot — a ServiceProvider or Application.php
MailTypes::register(new MailType(
    'digest',                             // recorded on every send
    'Weekly digest',                      // what a person reads on a preferences page
    'A summary of what happened, every Monday.',
    'digest'                              // the unsubscribe list — omit for transactional
));

Then one call at the send:

$mail->type('digest')->setTo($address)->setSubject($subject)->setBody($html)->send();

What the one call decides

Four things have to agree for a message on a list:

Without a type With one
List-Unsubscribe + List-Unsubscribe-Post offerUnsubscribe('digest') from the type
The visible link in the footer the same call from the type
Not sending to somebody who left the list not checked — call Unsubscribe::isOptedOut() yourself from the type
What the audit log calls it whatever was typed the type's name

The third row is the one that matters. offerUnsubscribe() alone puts a working link in the message and sends it even to an address that already used the previous one — so the reader unsubscribes twice and decides the sender is lying, which is what the spam button is for. A type()d send to an opted-out address returns false, sets getLastError() to say why, and still writes the mails row: «we did not send this, and this is why» is exactly what an audit log is for.

The list is what makes it optional

A type with a list is one somebody can turn off. A type without one is transactional and cannot be — a password reset, a second-factor code, a receipt. Not a judgement about importance: it is whether the message is a consequence of something the person just did. Those must arrive for somebody who unsubscribed from everything, mailbox providers do not ask you to offer an opt-out on them, and offering one anyway teaches people that the link does nothing.

An unknown type name is treated as transactional rather than raising. The thing that would throw is a send, so a typo would stop a password reset — it means one message goes out without a link it should not have carried anyway.

The framework's own

Registered without anybody asking, so a plain installation has a preferences page rather than only one that thought to declare its types:

Name Kind
newsignin optional — list newsignin
second-factor-code transactional
device-auth-link transactional
security-change transactional

Register the same name to override any of them — the label is what a person reads, and an application wording it differently, or in another language, should not have to work around the default.

The preferences page

After honouring the link, /unsubscribe lists every optional type with what it is and whether this address is receiving it, each row a link carrying its own signed token for that address and that list — so the page needs no session and cannot be edited into changing somebody else's settings.

That is the difference between a reader who wanted fewer emails keeping one of four and a reader who wanted fewer emails receiving none — which a sender would otherwise read as a clean unsubscribe rather than the failure it is.

a=in on a GET turns something back on. It is never honoured for one-click POST: RFC 8058 says a POST to that endpoint unsubscribes, and a provider that found a parameter turning it into a subscribe would be right to stop trusting the endpoint.

Nothing breaks without it

An application that registers nothing keeps working exactly as before. offerUnsubscribe() still takes a list, and mail without a declared type is transactional. This adds a way to say what you send; it does not require you to.

Unsubscribing, and what Gmail requires

Gmail and Yahoo require this of anyone sending in volume, and they are not asking for a gesture. A bulk message must carry List-Unsubscribe and List-Unsubscribe-Post, the one-click endpoint must work with no login and no confirmation step, and the request must be honoured within two days. A sender who fails is not told: the mail is quietly filed as spam, including the mail people wanted.

One call does all of it:

$mail = new \Pramnos\Email\Email();
$mail->to      = 'reader@example.com';
$mail->subject = 'This month at Example';
$mail->body    = $html;
$mail->offerUnsubscribe('newsletter');   // after `to` is set
$mail->send();

That sets four things that have to agree — the URL, the mailto: alternative, the one-click promise, and the list name the wrapper renders a visible link from. Set separately they can contradict each other, and a List-Unsubscribe-Post over a URL that shows a confirmation page is worse than no header at all: a provider follows it, gets a page, and counts the message as unhandled.

Before sending, ask:

if (\Pramnos\Email\Unsubscribe::isOptedOut($address, 'newsletter')) {
    continue;   // they asked us to stop
}

Notifications say which list they belong to

class WeeklyDigest implements NotificationInterface
{
    public function unsubscribeList(): string { return 'digest'; }
    public function toMail(mixed $notifiable): array { … }
}

MailChannel then does both halves without being asked: it skips an address that has opted out, and it offers the unsubscribe on the message it sends.

A notification that declares nothing is transactional and gets none of it — no link, no header, no suppression. That is the right default. A password reset must arrive even for somebody who unsubscribed from everything, mailbox providers do not ask you to offer an unsubscribe there, and a link on such a message teaches people that the link does nothing.

The framework's newsignin alerts are the one exception, because they have a real preference behind them: the account can already turn them off on its privacy screen, and honouring an unsubscribe flips that same checkbox.

Two records, two jobs

An unsubscribe writes two things, and the difference is the whole design:

pramnos.emailoptouts authserver.user_consents
What it is the suppression list the consent trail
Read by isOptedOut(), before every optional send somebody answering a GDPR request
Shape current state — opting back in deletes the row append-only, one row per grant and per withdrawal
Keyed by the address userid
Aged out never 7 years, by policy

Why not keep unsubscribes in user_consents alone? Because they need both. As the lookup it cannot be the consent table: that is append-only, so "may I send to this address" becomes "find the latest row for this user and this type and read its revoked_at", per send; it is keyed by account, and somebody on a list often has none; and its seven-year retention would eventually drop a withdrawal and start sending again.

As the record it is exactly right — a withdrawal of consent, with a legal basis and an address, which is what that table exists for. So the event goes there too, when the address belongs to an account and the auth feature is present. Best-effort in every direction: a consent trail must not be the reason an unsubscribe fails, which is the one failure a mailbox provider counts against every future message.

It is the same pairing the framework already uses for a mass message — the inbox row in messages, the delivery record in massmessagerecipients.

The token is signed, not stored

$token = \Pramnos\Email\Unsubscribe::token('reader@example.com', 'newsletter');

Nothing is written when a message goes out — a million-recipient send would otherwise write a million rows for links most people never open. The address and the list travel inside the token, signed with the installation's key, so an edited one fails verification and nobody can unsubscribe a stranger by changing a URL. There is no expiry, deliberately: people unsubscribe from a message they found six months later, and "this link has expired" is a sender making its own problem the reader's.

The endpoint

/unsubscribe is a framework controller and is public, which it has to be: a one-click request arrives from a provider's server with no session, and an address on a list does not always have an account at all.

Method Caller Behaviour
POST a mailbox provider, on the reader's behalf unsubscribes, answers 200, no confirmation
GET a person clicking the footer link unsubscribes and says so on a page

It is exempt from CsrfMiddleware by default, and that is not an oversight to be tidied away: Gmail has no token to send. A record that could not be written answers 500 to one-click, so the provider retries, and says so on the page rather than promising something that did not happen.

An application that wants its own look declares its own Unsubscribe controller, which takes precedence.

What a list is

A short name you choose — marketing, newsletter, digest — plus the reserved all, which suppresses everything carrying a link. Records live in emailoptouts, keyed on the address rather than a user id: an unsubscribe arrives from a mailbox, and often from somebody with no account — forwarded to, added to a list, inheriting an address.

For a list backed by a preference somebody can see, say what unsubscribing means:

\Pramnos\Email\Unsubscribe::handle('digest', function (string $email, string $list) {
    Digest::disableFor($email);
});

Otherwise a row the profile screen knows nothing about stops the mail while the checkbox still says it is on — a switch that lies to the person holding it.

isOptedOut() fails closed

Alone among this framework's reads, it answers true when it cannot tell. Sending to somebody who unsubscribed is the one mistake a provider counts against every future message, including the transactional mail this is never asked about. A message not sent during a database outage is a message the next run sends.

And everything else fails open, on purpose

The mirror image, and the reason it is worth writing down: nothing may make an unsubscribe fail. The suppression row is written first, and everything after it is best-effort:

What can go wrong What happens
no authserver.user_consents table (the auth feature is off) the consent event is skipped; the unsubscribe stands
the address has no account same — the suppression record is keyed by address, and is the whole story for a subscriber with no account
an application's handle() callback raises logged; the unsubscribe stands
the account's own preference cannot be written logged; the unsubscribe stands
the suppression row itself cannot be written optOut() returns false

That last line is the one a caller has to act on. An endpoint answering "done" while writing nothing leaves a person unsubscribing again, and again, from mail that keeps arriving — and their next move is the spam button, which is the outcome the whole feature exists to avoid.

A blank address gets no token

token('') returns an empty string, and url() and mailto() turn that into an omitted header rather than one pointing at nothing. A signed token for the empty address would verify, the endpoint would call optOut(''), be refused, and show the reader a failure for a link the framework had generated.

The rest of the compliance list

The parts that are not code:

  • SPF, DKIM and DMARC on the sending domain. Gmail requires authentication from every bulk sender; without DKIM the headers above will not save you. Check yours with mail:dns-check.
  • A From: domain you own, matching the DKIM signature. Not a free-mail address.
  • A reply address that a person reads. admin_replymail, and the mailto: unsubscribe uses it too.
  • Spam complaints under 0.10%, measured in Google Postmaster Tools — the number the unsubscribe link exists to keep down, because the alternative the reader has is the spam button.
  • Every message goes out as multipart/alternative with a plain-text part, which Email builds from the HTML — see the plain-text part.

Opt-in mailing lists: a newsletter

Everything above is opt-out: a list's mail goes to everybody who has not left it. That is right for a digest an account turned on in its settings. A newsletter is marketing, and GDPR and ePrivacy expect the other default — it goes only to those who agreed, and the agreement has to be provable. Declare the type optIn:

use Pramnos\Email\MailType;
use Pramnos\Email\MailTypes;

MailTypes::register(new MailType(
    'newsletter',
    'Newsletter',
    'News about the product, once a month.',
    list: 'newsletter',
    optIn: true,
));

A send of that type goes only to a confirmed subscriber; to anybody else it is refused and recorded, exactly as a send to an opted-out address is (Not sent: … has not subscribed to "newsletter"). MailTypes::allows() answers the same way. An opt-in type must name a list — new MailType(…, optIn: true) without one throws.

Subscribers, account or not

Pramnos\Email\MailingList keeps who asked, in pramnos.mailing_list_subscribers — one row per list and address, with the account's id when the address has one. The people waiting for registration to open are exactly the ones a landing-page form is for, so an account is not needed.

$lists = new \Pramnos\Email\MailingList();

$lists->subscribe('newsletter', $email, [
    'source'   => 'landing',          // form, account, wordpress, import…
    'consent'  => $sentenceShown,     // the words the person agreed to, as shown
    'language' => 'el',               // what to write to them in
    'ip'       => $ip,
]);                                   // pending, and a confirmation mail

$lists->isSubscribed('newsletter', $email);   // true only once confirmed
$lists->unsubscribe('newsletter', $email);

The rules it keeps:

Double opt-in A new address gets a confirmation mail and stays pending until the link is followed. The mail is transactional.
The link is not the confirmation It opens a page with a button. Mail scanners follow every link in a message and would otherwise confirm on the reader's behalf.
Signed and expiring A MailAction token for this list and address, valid seven days. It confirms only a pending row, so an old link does not put back an address that left.
At most one more mail an hour Asking again for a pending address resends only after RESEND_AFTER.
Proved addresses skip the mail 'confirmed' => true for an account's own verified address, or an address arriving on a signed link to it.
Leaving by any route ends it Unsubscribe::optOut() marks the row unsubscribed — the footer link, one-click, the preferences page; all ends every list.
Coming back restores only this list Confirming clears an opt-out of this list, not of all: somebody who left everything and then asked for the newsletter did not ask for everything else back.

subscribe() returns what happened (confirmation_sent, already_pending, already_confirmed, confirmed) for the caller's log. A public form must answer the same whatever it returns, or it becomes a way to find out who is subscribed.

The form: /mailinglist/subscribe

A framework controller, so it answers in every application with no wrapper:

<form method="post" action="/mailinglist/subscribe">
  <?php echo \Pramnos\Http\Middleware\CsrfMiddleware::tokenField(); ?>
  <input type="hidden" name="list" value="newsletter">
  <input type="hidden" name="source" value="landing">
  <input type="email" name="email" required>
  <p>News about the product, once a month.</p>
  <button>Subscribe</button>
</form>
  • The consent stored is the type's description, not anything the form sends — a trail whose sentence the visitor's browser supplied proves nothing. Show that sentence beside the button.
  • source is kept when it is a short identifier ([a-z0-9_-], up to 32), form otherwise; the language is the page's current one, and the address the request came from is recorded.
  • Refused before anything is written: a GET (405), a missing or stale form token (403), a list no opt-in type declares (404), a malformed address (400).
  • Every valid request gets the same answer — new, pending, already subscribed, or a failure that was logged — so the form cannot reveal who is on a list.
  • Ten requests an hour per address; past that, 429.
  • A request with Accept: application/json gets {"ok": …, "message": …, "state": "pending", "list": …}; a browser gets a page.

A single-page application needs mailinglist among the paths its web server sends to PHP; init includes it for new projects.

The confirmation page: /mailinglist/confirm

The link in the confirmation mail opens a page with a Confirm button; the button confirms. Two steps, because mail scanners open every link in a message, and confirming on the first request would subscribe everybody whose provider checks links. The token is the credential, so the button needs no session. An expired or foreign token, and a row no longer pending, each get a page that says so.

Where these pages render, and counting a confirmation

The subscribe, confirm and unsubscribe pages render in the theme's standalone layout, <theme>/login.php, when the theme has one (every theme pramnos init writes does). That layout carries the application's <head> (its stylesheets, its scripts, Tag Manager, the cookie banner) and its footer with a way back to the site, without the site header. A theme without that file gets the self-contained page instead, because its full chrome may need a signed-in user to render, and somebody arriving from a mail link usually is not one.

Each page says what happened, for a script to read. The content sits in an element with id="pramnos-standalone" and data- attributes:

data-state Page
pending after the form: a confirmation mail was sent
awaiting-confirmation the link opened, the button not pressed yet
confirmed the button confirmed the subscription
nothing-to-confirm the address was already confirmed, or left

data-list names the list. A Tag Manager trigger on #pramnos-standalone[data-state="confirmed"] counts the double opt-in, which is the one moment a newsletter's numbers are about.

On the server, the confirmation fires mailinglist.confirmed, wherever it comes from:

use Pramnos\Email\MailingList;
use Pramnos\Event\Event;

Event::listen(MailingList::EVENT_CONFIRMED, function (string $list, string $email): void {
    // count it, welcome them, tell the CRM
});

It fires once per address. A second press of the button confirms nothing and fires nothing.

On the account screen and the preferences page

Account → Privacy lists every opt-in type as a checkbox, off until the account joins — consent to marketing is not a default. Ticking it subscribes the account's own address: at once when the address is verified (validated = 1), with the confirmation mail when it is not, and the screen says a pending list is waiting to be confirmed. Unticking is the same as the unsubscribe link. The form posts lists[<list>].

/unsubscribe's preferences page shows opt-in types too, on only for a confirmed subscriber. Turning one back on from there subscribes the address outright, with source = preferences: the link is signed for that address, which proves the mailbox as well as a confirmation mail would.

The administration screen: /admin/MailingLists

Every opt-in list, with how many addresses are waiting to confirm, confirmed and left; choose one to see its subscribers, newest first, fifty to a page, searchable by part of an address and narrowed by state. Each row says where the person joined (source), in which language, and when they asked, confirmed and left, and links to the account where there is one.

Action What it does
Send confirmation again on a pending row: the confirmation mail once more, whatever the hourly limit a public form is held to
Unsubscribe the address leaves the list exactly as by the footer link — the row ends, the opt-out and the consent event are written
Export confirmed (CSV) email, language, confirmed_at, userid for the list's confirmed addresses

Both row actions are POST with the session's token. Nobody is added on this screen: an opt-in list holds the people who asked, with the proof that they did, and an address typed in by an administrator has no consent behind it.

The screen is MailingListsController (ability admin.mailinglists, under System in the menu). init writes its wrapper, src/Admin/Controllers/MailingLists.php; a project scaffolded before it existed adds the same three-line class:

namespace App\Admin\Controllers;

class MailingLists extends \Pramnos\Application\Controllers\MailingListsController
{
}

In code, the same reads and writes: MailingList::lists(), counts(), subscribers($list, $status, $search, $page, $perPage), resendConfirmation($id), unsubscribeSubscriber($id), confirmedExport($list).

On an account's administration page

/admin/users/view/{id} has an Email panel: every kind of mail the person can turn off and every opt-in list, in its state for the account's address, and whether the address left everything. What an administrator can do there, each POST with the token and written to the account's activity log:

For Off On
mail sent unless they say stop the unsubscribe, source = admin clears that list's opt-out only — Unsubscribe::clearOptOut() — and records the consent
an opt-in list the unsubscribe sends the confirmation mail; the subscription starts when they confirm
everything the unsubscribe from all clears the all opt-out, leaving each list's own

"On" is for the person's own request — the buttons say so before they act. An administrator can ask for consent to a list, never give it.

Each row keeps the sentence the person agreed to, where they agreed (source), the address the request came from, and when they asked, confirmed and left. For an address with an account, confirming also writes an authserver.user_consents event, as leaving does.

The rows are part of the account's data: Account exports them under Mailing lists and erases them, found by account and by address, so a subscription made before the account existed goes with it. The table is on the personal-data denial list, with emailoptouts.


The line the inbox shows beside the subject

The second most prominent piece of text in an inbox, and worth choosing deliberately.

Every mailbox list — Gmail, Apple Mail, Outlook — prints the message's first readable text next to the subject. On a wrapped message that is whatever the wrapper opens with: a logo's alt, a "view this in your browser" link, the first cell of a layout table. Left alone, the line that decides whether the mail is opened is an accident of the template.

$mail->preheader('Your code is 481920 — it expires in ten minutes.');

Or on a notification, beside the other mail options:

(new Message('Your export is ready', $body))->preheader('It is on your downloads page.');

Left unset it is derived from the body's own opening, cut to 100 characters and flattened to one line. That is deliberate: "no preheader" is not a neutral state, and the body's first sentence is at worst a repetition of what the reader is about to see — which is what most marketing mail does on purpose.

The wrapper renders it hidden three ways, because no single one works everywhere: display:none, mso-hide:all for Outlook, and a 1px transparent colour for the rest. It is padded with zero-width non-joiners so a client does not follow it with the next thing it finds, which is how "Your code is 481920 View this in your browser Unsubscribe" ends up in somebody's inbox.

PlainText drops display:none, so a hidden preheader does not open the text part either.

Dark mode, and why the wrapper declares it

<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">

Without these, Apple Mail and Outlook invert the colours themselves. Their inversion is per-element and knows nothing about images, so a dark logo on the white card it was drawn for ends up black on near-black, and a hairline border becomes the loudest thing in the message. Declaring support stops the client guessing and hands the decision to a @media (prefers-color-scheme: dark) block.

Every colour that block overrides is also inline. Gmail strips <style> from a forwarded message and several clients drop it outright, so the block is an improvement where it survives and never the thing keeping the message readable. A wrapper of your own should keep that property: inline the light palette, and let the stylesheet only change it.

Accessibility in the message

Three things a mail template gets wrong by default, all of them handled in the bundled wrapper:

  • role="presentation" on every layout table. Without it a screen reader announces "table, two columns, row one of three" about a message that has no table in it — the layout is read aloud as data. A table that really is data should not have the attribute.
  • lang on <html>, from the language the message was composed in — which Notifier and the mass dispatcher both switch to the recipient's before composing. An empty lang is worse than a wrong one: the reader falls back to its own setting silently, and announces Greek as if it were English.
  • 16px body text, not 12. Mail is read on phones, and a small-print footer is the smallest thing on the least-read part of the message.

And alt on every image, including the decorative ones, where it should be alt="" — an empty alt tells a screen reader to skip the image, while a missing one makes it read the filename.

The plain-text part

Every message is multipart/alternative. The obvious way to build the text half, strip_tags($body), produces a part which is technically present and practically useless, in three specific ways:

  • Every link disappears. strip_tags keeps the anchor text and throws the href away, so «click here to confirm your address» arrives with nothing to click and no address to copy. On a confirmation mail that is the entire message gone.
  • The text runs together. HTML mail is nested tables, and adjacent cells have no whitespace between them, so a header, a heading and a paragraph arrive as one line.
  • The stylesheet comes along. strip_tags removes the <style> tags and keeps what is inside them, so a reader in a text-only client is shown the CSS.

And a text part that does not match the HTML is a documented spam signal.

Pramnos\Email\PlainText converts instead, and Email uses it for every message:

use Pramnos\Email\PlainText;

$text = PlainText::fromHtml($html);
Example <https://example.com>

Confirm your address

Hello Yannis, please click here to confirm
<https://example.com/confirm?t=abc123>.

- One
- Two

Device | Last seen
Chrome | 28/08/2026

Written against DOMDocument, and with no new dependency: an html-to-text package is a reasonable choice for an application and the wrong one for a framework, which would impose it on every project that ever sends a message.

The decisions in it worth knowing, because each one is a wrong output avoided:

It does Because
text <https://…>, and just the URL when the text already is the URL A text-only reader has to be able to reach the same place — and https://… <https://…> reads as a mistake
Drops <head>, <style>, <script> and <title> entirely Otherwise the text would begin with the CSS and then repeat the subject line
Reads role="presentation" to tell a layout table from a data one — and treats a table whose cells hold paragraphs, pictures or tables as layout too A layout table's cells joined with | is nonsense; a data table's rows joined with newlines is unreadable. The distinction is already in the markup for screen readers, and a template that does not say still shows it
Leaves out a link with nothing to read — a logo, an icon without alt text A bare address on a line of its own says nothing; a header logo made one the message's first line
A run of links — a menu, a footer — one link a line, and text <url> never split Run together, they wrapped between a link's words and its address
Leaves out anything marked data-pf-plain="omit" A template's decoration — a header logo, a social bar — belongs to the HTML
Skips display:none and zero-height elements A preheader is written for the inbox preview and is invisible in the HTML; repeating it in the text is a difference between the two halves
[alt text] for an image with one, nothing for a decorative one A line reading [] is worse than no line
Wraps at 78 columns but never breaks a URL A wrapped URL is an unusable URL: the client links the first half and leaves the rest as text

Writing the text part yourself

A designed template converted is still the whole layout around the message. An application that knows better writes the text part itself — the message and a short footer:

$email->setBody($designedHtml)
      ->setTextBody(PlainText::fromHtml($messageBeforeTheTemplate) . "\n\n-- \nExample Ltd · example.com");

That is the text part that goes, queued or not. Where the message offers an unsubscribe link and the text does not carry it, Unsubscribe: <url> is added at the end. It is kept inside the stored body (Pramnos\Email\TextPart) — as a <script type="text/plain" data-pf-text> after the HTML of the copy kept, never of the message sent — so it is compressed and shared as bodies are, in every store mode, and no column of mails grows by it. The message's page shows the text that went; sending it again sends it as the text part.

The charset is declared with a <meta> tag prepended to the markup rather than with mb_convert_encoding($html, 'HTML-ENTITIES', …), which is deprecated as of PHP 8.2 — and without either, libxml assumes ISO-8859-1 and every Greek character becomes mojibake.


The headers a message carries

All five are added automatically — two on every message, three on list mail. None changes what the reader sees; all of them change what happens to the message — which is why they go missing. Nothing in mail reports a missing header back to the sender, and nothing reports a malformed one either.

Header On Why
Auto-Submitted: auto-generated every message RFC 3834. Stops an out-of-office responder replying to a password reset — and then to the reply, which is how a mail loop starts
X-Entity-Ref-ID every message Gmail groups by subject, and «a new sign-in to your account» repeats. Without a unique id two sign-ins arrive as one thread with the older behind "show trimmed content" — exactly the message somebody needs to see twice
Precedence: bulk list mail Not in any standard, honoured nearly everywhere. The older half of the same idea as Auto-Submitted. Not on transactional mail: marking a password reset bulk invites a provider to deprioritise the one message the reader is waiting for
List-ID (RFC 2919) list mail A stable identifier, so a client can group, filter and unsubscribe by list rather than guessing from the subject
Feedback-ID list mail Google Postmaster's grouping key. Without it every complaint lands in one bucket and the dashboard can say something is wrong but not what; with it, «the newsletter is marked as spam and the receipts are not» becomes a fact

A header you set yourself wins. These are defaults, not policy — an application with its own Feedback-ID scheme distinguishing campaigns, which is the whole point of that header, must not have it overwritten by a generic one.

The host in List-ID and Feedback-ID comes from the From: address, falling back to the site URL; with neither, both headers are left out rather than invented, because a stable identifier for the wrong domain is worse than none. And a list name is reduced to the characters those headers allow: Feedback-ID takes letters, digits, _, . and -, and one bad character or one over-long field invalidates the whole header.


A reply in the same thread

Mail clients thread a conversation on In-Reply-To and References, which hold the message ids of what came before. A reply goes into its original's thread when it names that message:

// The original, with an id the application chose and stored:
$id = bin2hex(random_bytes(16)) . '@customer.example';
$original->withMessageId($id)->send();

// Later, the reply:
$reply->setSubject('Re: ' . $subject)
      ->inReplyTo($id, $referencesOfTheOriginal)
      ->send();

inReplyTo() writes In-Reply-To and builds References as the original's references followed by the original. Without withMessageId() the transport makes an id up, and nothing records it, so choose and store one for any message that will be replied to. Angle brackets are optional everywhere. For a message that arrived from outside, its own Message-ID header is the id to pass.

addHeader('In-Reply-To', …), References and Message-ID go through the same path, in any letter case. An id that is not local@domain is refused when the header is added, with the header named, not by the transport during the send.

A threaded message can be queued like any other: the outbox keeps its headers (see The outbox).

Gmail actions, and the brand mark beside the subject

Gmail looks for application/ld+json in a message and, when it finds a block it recognises, draws a control in the message list — a "Confirm" button beside the subject, before the message is opened. That is the difference between a confirmation that takes one tap and one that takes four.

use Pramnos\Email\Actions;

$mail = new \Pramnos\Email\Email();
$mail->setSubject('Confirm your address')
     ->setBody($html)
     ->addStructuredData(Actions::confirm(
         'Confirm address',
         'https://example.com/confirm/abc123'
     ))
     ->setTo($address)
     ->send();
Builder Draws Note
Actions::confirm($name, $url) a one-tap confirm button The URL must act on the first request — see below
Actions::view($name, $url) a link promoted to a button target, not handler: a place to go rather than something to call
Actions::save($name, $url) "save this offer"
Actions::rsvp(['yes' => …, 'no' => …, 'maybe' => …]) yes / no / maybe Three handlers; the answer is which URL was called
Actions::sender($name, $logo, $url) the brand mark beside the subject The "highlight" half — same block, not an action
Actions::promotion([...]) a card in the Promotions tab Wrong twice over on transactional mail: it is not a promotion, and it invites the classifier to file a password reset where nobody looks

Before you conclude it is broken

Gmail displays none of this until the sending domain is registered with Google. That is the first thing anybody hits, it is invisible from inside the application, and it is not a bug in this code. Actions::requirements() returns the list as data for exactly that reason:

  • the sending domain must be registered with Google;
  • SPF or DKIM must authenticate the From: domain, and DMARC must pass;
  • the action URL must be HTTPS on a domain you control;
  • a ConfirmAction handler must act on the first request — no confirmation page, no sign-in. Gmail sends a POST and does not follow up;
  • one action per message: Gmail shows the first it understands and ignores the rest.

Everything is still correct and harmless without registration. Other clients ignore what they do not understand, and the markup is invisible in the rendered message.

One-click actions: MailAction

A ConfirmAction needs a URL that acts on the first request, with no confirmation page and no sign-in, because Gmail issues one POST and does not follow up. Pramnos\Email\MailAction is that endpoint, generalised from the one the framework already had — RFC 8058 one-click unsubscribe — so an application adds its own in three lines rather than writing a controller, a token format and a signature check.

// once, in a service provider
use Pramnos\Email\MailAction;

MailAction::register('confirm-order', function (array $claim): bool {
    return (new Order((int) $claim['order']))->confirm();
});

// in the mail
$url = MailAction::url('confirm-order', ['order' => 42], 172800);
$mail->addStructuredData(Actions::confirm('Confirm order', $url));

That is the whole integration. /mailaction is a bundled controller, so there is no route to register, and an application that wants its own look declares its own MailAction controller, which takes precedence.

The token is the whole authorisation

There is no session and no CSRF token, because the caller is a mailbox provider's server and neither exists. That is not something introduced here — a password-reset link has always worked this way — but it decides everything else:

  • Signed, with a key stored as mailaction_secret on first use. Rotating it invalidates every outstanding link.
  • Expiring, and the expiry is inside the signed material. An expiry beside the signature is one the holder can edit, which makes it advice.
  • Naming one action and one payload. The payload is readable by anybody holding the token, so it carries identifiers, never secrets.
  • verify() answers null for a forgery, a malformed token and an expired one alike — one answer, because distinguishing them tells somebody probing how close they are. expired() exists separately so a page can say "this link has expired, ask for another", which is useful and safe to say.

GET does not act, unless you say it may

MailAction::register('verify-address', $handler, actOnGet: true);

By default a GET performs nothing and a person following the visible link is shown a page with a button. That is not ceremony: a GET is issued by things nobody asked for — a link scanner in a corporate mail gateway, a client prefetching to build a preview, an antivirus proxy — and if a GET acted, those would act. Gmail sends a POST, so the button works either way.

Opt in when the effect is safe to trigger that way. Confirming an address is the clear case: whoever holds the message has already proved the point.

What the handler owes you

It must be idempotent. Gmail retries, a reader may press twice, a client may prefetch. Confirming an already-confirmed thing is a success — returning false there turns a second press into a 500, and on a provider that retries 500s, into a loop.

false means "could not, try again": it becomes a 500, which is correct, because the usual cause is a database that was briefly away and that is exactly what retrying fixes. A thrown exception is caught, logged and reported the same way, without showing the reader the internals.

Situation A machine gets A person gets
Done 200 a page saying so
Invalid token 400 "this link is not valid"
Expired 410 "this link has expired"
Registered nowhere 501, naming the action the same, so the cause is findable
Needs a POST 405 a page with a button
Handler failed or threw 500 "could not complete this"

The 501 matters more than it looks: a valid token for an action nothing handles is almost always a handler registered in a service provider that did not run — a feature switched off, a provider removed — and answering "not valid" would send somebody to inspect the token instead of the registration.

What ships registered

revoke-sessions — the handler a "this wasn't me" button needs, registered by the authserver feature's service provider (so present only when that feature is enabled). It ends every session on the account, and the framework's own new-sign-in alert does not use it; see below.

$url = MailAction::url('revoke-sessions', ['user' => $userId], 3600);
$mail->addStructuredData(Actions::confirm('It wasn\'t me', $url));

A short TTL is right for that one: the link ends every session on the account, and a message that sat in a mailbox for a month should not still be able to.

Where the framework uses this itself, and where it will not

The password-reset mail carries a ViewAction. That mail contains exactly one link, which is its entire purpose, so an action pointing at it exposes nothing the message did not already expose — and turns four taps on a phone into one. A ViewAction needs no handler and makes no promise about the first request, so there is nothing to build for it.

No ConfirmAction on the framework's own mail — although the handler for one exists. The contract is real, and MailAction above is how it is met: revoke-sessions is registered and ready. What the framework does not do is put it in a message, because the only message it would belong in is the new-sign-in alert, and that is the paragraph below.

The new-sign-in alert carries no action at all, and that is a decision. It contains no link either: a link in an unexpected security email is the shape of the attack it warns about, and a button in the message list is the same thing, larger and easier to press. The message tells the reader to open the site themselves. The revoke-sessions handler makes a one-tap "this wasn't me" possible; adding one to this alert would be a reversal of that judgement — an application's call to make for its own users, not something the framework assumes.

Where the block goes

Into the <head>, which is where Gmail's own documentation puts it and where a <script> cannot disturb the layout. A body fragment with no head gets it before </body>; a message with neither gets it prepended, so the feature does not depend on which template an application uses.

It is encoded through Html\Seo::jsonLd() rather than json_encode(). That matters: a </script> inside any value ends the block early and everything after it is parsed as markup, and these values come from record titles and user input. And it never reaches the plain-text part, because the converter drops head and script outright.

An empty array is ignored, so addStructuredData(Actions::rsvp([])) is safe. A builder with nothing to describe returns nothing — a <script> containing [] would be a claim that the message has no actions, which is a different statement from making no claim.


Tracking: opens and clicks

Tracking is off unless three separate things are true. That is the design, not a precaution:

  1. 'email' => ['tracking' => true] in app.php. Absent means off.
  2. The message belongs to a list — offerUnsubscribe() was called on it. Transactional mail is never tracked at any setting.
  3. enableTracking() was called on that message.
$mail->setSubject('This month at Example')
     ->setBody($html)
     ->setTo($address)
     ->offerUnsubscribe('newsletter')   // the consent, and what makes it a list
     ->enableTracking()
     ->send();

Gate 2 is the one that matters. A pixel in a password reset is a remote image in the most sensitive message a system sends, to somebody who agreed to nothing — so enableTracking() only requests tracking, and the pixel is added at send time only when all three gates hold.

An open is a weak signal, and getting weaker

This is the part worth reading before deciding the feature is useful.

  • Apple Mail Privacy Protection, on by default since iOS 15, fetches every remote image through Apple's proxy the moment a message arrives — whether or not anybody ever opens it. Uncorrected, that reports an open for every Apple recipient, minutes after sending.
  • Gmail proxies and caches images. The fetch comes from Google, so the IP tells you nothing about the reader, and later opens may never reach you at all.
  • Many clients block remote images, Outlook for external senders among them. A real open records nothing.

So opens and proxy fetches are counted in separate columns and never added together:

Column Means
opens A fetch that did not come from a known mailbox proxy. The closest thing to a reader.
proxy_opens A provider fetched it on delivery. Says the message arrived, not that it was read.
clicks Somebody followed a link. This is a person.

A single "opened" figure is how a message nobody read gets reported at a 70% open rate. If you take one number from this feature, take the clicks.

Proxies are recognised by user agent, plus the two networks that fetch on delivery — Apple's identifies as Safari, so the user agent alone cannot name it. A proxy that stops identifying itself will be counted as a reader, which is the honest limit of the method.

Clicks

Tracking::wrapLinks() rewrites every http(s) link so following it is recorded and the reader is redirected. Left alone: mailto:, tel:, in-page anchors, and the unsubscribe link — a reader unsubscribing is exercising a right, and routing that through a tracker is both distasteful and a way to break the one link a mailbox provider tests.

A click link works for thirty days from the moment the message was sent as: the date its mails row records, which for a queued message is when it was queued. Tracking::linkSentAt() and wrapLinksSentAt() take that moment, and MailAction::tokenUntil() an expiry. The same message, link and moment give the same link, so a stored body can be given its links again exactly as they were sent. link() and wrapLinks() count from now.

The destination lives inside the signed token, never in the URL. A tracker that reads its destination from a query parameter is an open redirect, and an open redirect on a domain that sends mail is a phishing kit somebody else gets to use: the link comes from your domain, in a message that looks like yours, and lands wherever the attacker chose.

The routes, and the tables

/emailpixel and /emailclick are bundled controllers — no route to register.

emailtracking holds one row per tracked message; emailtrackingclicks holds one row per link followed, because which link is the only question worth asking of a click. Both are created by a migration.

The pixel always answers with the image, whatever happened behind it — an unknown id, a database that is away, a message that was never tracked. A broken image in the middle of a message is a worse outcome than a lost measurement.

An open or a click is something the person did

When the mail log ties a tracked message to an account (mails.userid), a counted open and a click are also recorded as that person's events — email.opened and email.clicked, with the message, its list and the link — so a filter or a rule can ask "opened a newsletter in the last 30 days" (Audience guide). A proxy fetch is not an open there either. Best-effort, like the rest of tracking.

One campaign's opens and clicks

A mass message's page shows its tracked total, how many people opened it, how many were fetched only by a mailbox provider, and how many clicked — counted per recipient, so two clicks by one person are one person who clicked. Who opened and clicked (/admin/MassMessages/tracking/{id}) lists the recipients with their opens, provider fetches, clicks and first times, narrowed to ?show=opened, clicked or unopened, and the links followed with how often and by how many.

A campaign is found through the mails rows its sends wrote: module = massmessage, moduleinfo = the message's id. Any module can do the same by setting $email->moduleinfo before sending, and read it back with:

Tracking::campaign('massmessage', '42');              // tracked, opened, proxyOnly, clicked, clicks
Tracking::campaignRecipients('massmessage', '42', 'clicked');
Tracking::campaignLinks('massmessage', '42');         // url, clicks, people — most followed first

A message whose mails row has no moduleinfo belongs to no campaign: its opens and clicks are on each message's own page on the email history screen.

Privacy

This is processing personal data. Disclose it in the privacy policy the list's subscribers agreed to, keep the unsubscribe working, and do not switch it on for transactional mail — the framework will not let you, but the reasoning is worth carrying into whatever you build beside it.

What was actually sent

/admin/emails lists every outbound message from the mails audit log — recipient, subject, module, date, status — with the full body and a resend. Tracked messages show their figures in an Opens column, with prefetches marked apart. A message that was not tracked shows a dash, which is the honest rendering of "nobody measured this".

The status reads Sent, Queued or Failed (Mail::STATUS_*), and a failed badge carries the reason in its title — read from extrainfo: the outbox's last_error, an error in an application's serialized array, or the text itself. Send this email again is a POST with the session's token. A message that never went is queued again in place and dated now, so the outbox's deadline counts from the resend; a sent one is not offered at all.

An application shows the screen in its own terms by extending EmailsController:

class Emails extends \Pramnos\Application\Controllers\EmailsController
{
    /** The filter's options and the badges' words, as value => label. */
    protected function statuses(): array
    {
        return parent::statuses() + [3 => t('Canceled')];
    }

    /** Columns of its own after Status — shown escaped, and searchable. */
    protected function extraColumns(): array
    {
        return ['From' => 'frommail', 'Sender name' => 'fromname', 'Recipient name' => 'toname'];
    }

    /** "It never arrived": a sent message may go again. */
    protected function mayResend(array $row): bool
    {
        return true;
    }
}

statusBadge(int $status, array $row) draws one status cell, on the list and on the message's own page. extraColumns() takes column names only — anything else is dropped, because the list builds its query from them. A resend mayResend() allows for a sent message queues a new row, a copy with the same body, names and outbox settings, and leaves the first as it was: the history says the message went twice, which is what happened.

A message's own page draws the body in a sandboxed frame, scripts off, from MessageReport::preview(): without the tracking pixel, so looking at a message is not counted as its recipient opening it, and with <base target="_blank">, so a link opens in a window of its own. The tracked links stay — following one from the preview is how an administrator checks that clicks are recorded — and the Tracking tab and its count are fetched again when the reader comes back to the page (data-pf-refresh-on-return in pf-utils.js, on any element with an id).

Every send() writes that row, sent or failed. An application that already keeps its own row for each message in the same table gets two; it turns the framework's off for every send, including Email::sendMail():

// app/app.php
'mail' => ['record' => false],

or for one message with $email->recordToMails = false. With recording off, queue() has no outbox row to write and sends at once.

The account a message was for

(new Email())->setTo($user->email)->forUser($user->userid)->setSubject($s)->setBody($b)->send();

forUser() records the account in the row's userid, and a notification's mail records its notifiable's account on its own. An account's mail is then its own whatever address it went to: the account's page counts and lists it, its "See all" opens the history with ?user=, and the history names the account in a column — messages logged before the column, or to an address with no account, are found by the address as before (Pramnos\Email\MailLog::ofUser()). A message's "← Back" returns to the page it was opened from.

The column comes with a migration, add_userid_to_mails, which also builds an index (userid, date) over the whole log. A log of tens of millions of rows can leave it for a maintenance window — 'migrations' => ['exclude' => ['add_userid_to_mails']] — and until it runs everything matches by address.

Where a sent message's body is kept

A sent message's body is not kept in its mails row but in the body store (Pramnos\Email\BodyStore):

// app/app.php — these are the defaults
'email' => ['bodies' => [
    'store' => 'auto',        // 'database', 'files' or 'content'; auto: database where mail_bodies exists
    'path'  => 'var/emails',  // files: under the application's root, or an absolute folder
    'disk'  => null,          // files on a storage disk instead, by its name in 'storage' — S3, say
]],
Store Where The row's path
database mail_bodies, a compressed hypertable. Its migration creates it only on TimescaleDB db:
files var/emails/2026/10/19/13/<md5>.body.gz, one per distinct body and hour relative to the root, or disk:<name>:<path>
content the row's content, as before empty

auto is database where the table exists and files elsewhere. Measured on real bodies, the hypertable keeps them in about a seventeenth of their size. A gzipped file each saves little once the filesystem's 4 KB blocks are counted.

What is kept is the body before what differs per recipient. The tracking pixel and the tracked links are added as the message is sent. The unsubscribe link the framework issues is kept as a placeholder naming the list; a link of the application's own is kept as it is. So a newsletter to a hundred thousand people is one body. The Gmail actions' structured data is kept in the body. hash is the md5 of what is kept.

Reading gives back what was sent. Pramnos\Email\Body::of($row) puts the reader's unsubscribe link back, and for a tracked message wraps the links and adds the pixel as of the moment it was sent (Tracking::wrapLinksSentAt()). The result is the HTML that left, byte for byte, as long as the site's address and the signing secret have not changed. - Where it is used: everything that shows or sends a stored message reads through it — the history screen, its report, the outbox worker, the debug panel's sign-in code — and Messaging\Mail::body() does the same for the model. - Bodies an application kept itself: content, or a gzipped .html.gz file its path names relative to the root, are read as they are. - What a path may read: a path is read only from inside the root or the store's folder, and only as a body's kind of file (.gz, .html), so a row cannot point it at anything else.

A queued message keeps its body in content for the worker, as composed. Once sent, it moves to the store.

A templated message: kept once, filled for each

A message that is the same for everybody but a few values hands the framework the template and the values, rather than the filled body:

$mail->setTo($user->email)
     ->setSubject('Hello {firstname}')
     ->setBodyTemplate('<p>Dear {firstname}, your plan renews on {renewal}.</p>', ['renewal' => '1/11'], $user)
     ->send();

The values are the account's (TemplateValues::forUser()) with the ones given over them. The subject's placeholders are filled too. The body kept is the template, wrapped, once for every recipient of it, and the values go into the row's bodyvars. Reading fills it again.

The order is what makes it exact. A templated message is wrapped as the template and filled afterwards, so what was sent is, by construction, the kept template filled. The preheader a wrapper prints comes from the template, filled, and so does the subject in its <title>.

Placeholder Value
{firstname}, {first_name}, {lastname}, {last_name}, {full_name} the account's names
{username}, {email}, {id} the account's
{url}, {login_url} (and {Login_url}) the site, its sign-in page

The account's values are its own text, so they are escaped: a name with a < in it is shown, never read as markup. An application adds its own values with a provider:

// app/app.php
'email' => ['template_values' => App\Mail\Values::class],   // implements Pramnos\Email\TemplateValueProvider

They win over the account's, and go in as given: HTML stays HTML, so a {photo} can be an <img>. A value that is the user's own text is the provider's to escape. A placeholder nobody fills is left as it is, so the mistake shows in a test send.

A queued templated message is kept filled. Without the bodyvars column, before its migration has run, a templated message is kept filled as well. Either way it reads back the same.

A store that cannot be written keeps the body in content and logs why; a body is never lost to its store. The application has to be able to write the files folder.

Moving bodies from files into the database. An installation that kept its bodies on disk, and has TimescaleDB now, moves them into mail_bodies:

php bin/pramnos mail:bodies-to-database --dry-run          # how many rows there are to move
php bin/pramnos mail:bodies-to-database                     # a thousand rows a transaction
php bin/pramnos mail:bodies-to-database --include-content   # bodies kept in mails.content as well
  • What it reads: each row's body as it is kept — an application's .html.gz, the store's .body.gz, a file on a storage disk. It then points the row at mail_bodies (path = db:), so the row reads exactly as before.
  • Stopping and starting: a row already moved is not read again, so a run that was stopped is finished by the next. A batch the database refuses is rolled back whole, and the run stops, saying at which mail.
  • A file that is not there any more is counted, and its row left as it is.
  • The files are not deleted, because many rows share one. Once a run finds nothing left to move, the folder is yours to remove.

What DNS says, and what the application cannot see

./yourapp mail:dns-check
./yourapp mail:dns-check example.com --selector=mail

The one part of deliverability that is not in the message. SPF, DKIM, DMARC and BIMI are records on a domain: this framework can compose a perfect email, set every header a mailbox provider asks for, and still have it filed as spam — with nothing in any log. The only symptom is mail quietly not arriving, reported months later as "I never got the password reset".

Sending domain: example.com

  ok   SPF — Present.
       v=spf1 include:spf.protection.outlook.com -all

  ??   DKIM — Not checked — a DKIM record lives under a selector, and the selector is chosen
       by whatever signs the mail.

  ok   DMARC — Present, but `p=none` — it asks for reports and enforces nothing.
       v=DMARC1; p=none; rua=mailto:dmarc@example.com

 gone  BIMI — No BIMI record — no logo beside the subject.

The command exits non-zero when the domain does not meet the bulk-sender bar, so it can sit in a deploy check — this is exactly the kind of thing that is correct on the day it is set up and wrong two domain transfers later.

Three judgements in it are worth stating, because a check that cries wolf is one nobody reads:

  • An unchecked DKIM is not a failed DKIM. The selector belongs to whatever signs the mail, often a relay, so an application frequently does not know it. Pass --selector and it becomes a real check; without one it reports "not checked" and does not fail the domain.
  • p=none clears the bar. Gmail and Yahoo ask for a DMARC record. p=none is one, and failing everybody at p=none would be failing most of the internet — so the verdict passes and the finding ("somebody forging this domain is still delivered") stays visible beside it.
  • BIMI is not deliverability. It is a logo, a Verified Mark Certificate is bought and needs a registered trademark, and an installation without one is not misconfigured.

Two states the command reports that a simpler "found / not found" check would call success:

  • Two SPF records, which is a PermError under RFC 7208 — a receiver gets no result at all, so two records authenticate strictly less than one. It is a common state, because each was added by a different person for a different service.
  • A DMARC record at p=none, which every tool reports as "DMARC found" and which enforces nothing — and silently stops BIMI from ever working.

BIMI, in order

The logo beside the subject, and the only item here that is worth money.

  1. DMARC at p=quarantine or p=reject. Not optional and not negotiable; p=none disables BIMI at every provider.
  2. An SVG Tiny PS — a restricted SVG profile, square, with a solid background — at a public HTTPS URL.
  3. A TXT record at default._bimi.<domain> reading v=BIMI1; l=https://…/logo.svg.
  4. A Verified Mark Certificate, referenced with a=. Gmail and Apple show nothing without one; a few other providers will show the logo on the record alone. A VMC requires a registered trademark and is bought from a certificate authority.

mail:dns-check reports each of these separately, so "we published BIMI and nothing happened" has an answer.

Bounces, and addresses mail does not go to

An address that does not exist, or that its owner asked to be cut off entirely, receives nothing — a password reset included. That is the difference from an unsubscribe, which only stops mail on a list: sending to a dead mailbox is what a mailbox provider counts against the sender, whatever the message was.

use Pramnos\Email\Bounces;

Bounces::refusal('someone@example.com');   // why mail does not go to it, or null when it does
Bounces::block('someone@example.com', 'Asked by phone to stop all mail');
Bounces::clear('someone@example.com');     // its bounces and its block forgotten
Bounces::record('someone@example.com', soft: false, reason: '550 5.1.1 no such user');
Bounces::recordReport($rawMessage);         // every failed recipient of a delivery report

send() and queue() ask before composing. A refused address is a message not sent: send() returns false, getLastError() says why (Not sent: someone@example.com hard-bounced 1 time(s), last … (550 5.1.1 no such user).), and the mails row is written as not sent with that reason, as an opted-out one is. A message to several addresses goes to the ones it may go to and is refused only when none is left. mail:flush asks again before sending a queued message, so an address that bounced after the message was queued does not receive it.

When an address is refused

  • it was blocked by hand;
  • it hard-bounced — no such mailbox, no such domain — once;
  • it soft-bounced — mailbox full, disabled, the server deferring — 15 times; or
  • it soft-bounced within the last day: a full mailbox is given time to empty.

Each rule is the application's to set, and 0 turns one off:

// app/app.php
'email' => ['bounces' => ['hard' => 1, 'soft' => 15, 'softWithin' => 86400]],

Where bounces come from

A delivery report (RFC 3464) — the message a mail server sends back — is read for every recipient whose action is failed. A status 4.x.x is soft, and so are 5.2.1 (disabled) and 5.2.2 (full), the mailboxes that exist; every other permanent failure is hard. A delayed delivery records nothing.

php bin/pramnos mail:bounces var/inbox            # every .eml in a folder
php bin/pramnos mail:bounces var/inbox --delete   # … removing each report once recorded
php bin/pramnos mail:bounces < report.eml         # one message on standard input
php bin/pramnos mail:bounces --status=someone@example.com
php bin/pramnos mail:bounces --block=someone@example.com --reason="asked by phone"
php bin/pramnos mail:bounces --clear=someone@example.com

Reading the bounce mailbox over IMAP

php bin/pramnos mail:bounces --imap               # the unseen messages of the bounce mailbox
php bin/pramnos mail:bounces --imap --limit=50
// app/app.php
'email' => ['bounces' => ['mailbox' => [
    'host'       => 'imap.gmail.com',
    'port'       => 993,
    'encryption' => 'ssl',          // 'tls' for STARTTLS on 143, 'none' for a server on this host
    'username'   => 'bounces@example.com',
    'password'   => '…',            // or, for Gmail and Microsoft 365:
    'oauth'      => ['client_id' => '…', 'client_secret' => '…', 'refresh_token' => '…'],
    'folder'     => 'INBOX',
]]],

The unseen messages are read without being marked. Each delivery report is recorded and marked seen. Anything else stays unread, for a person — so a bounce mailbox that also receives replies needs somebody to read them, or they pile up. --limit takes the newest unseen messages, so a new report is reached however many unread replies are older than it. With oauth the refresh token is exchanged for an access token (Google's endpoint unless token_url says otherwise), and the mailbox is signed into with XOAUTH2: Gmail accepts no password from an application, and IMAP needs nothing more. Without the block, the settings an application already keeps are read: - imap_host, the imap extension's {imap.gmail.com:993/imap/ssl}INBOX form included; - imap_user and imap_pass; - imap_oauth_client_id, imap_oauth_client_secret and imap_oauth_refresh_token.

PHP no longer ships its imap extension, so this speaks IMAP itself, through Pramnos\Email\ImapClient. An application can use the client directly, for any mailbox it reads: - connect(), then login() or authenticate() (XOAUTH2), then select(); - unseen() and seenBefore($day); - fetch(), which reads a message without marking it; - markSeen(), delete(), expunge() and logout().

Standard input makes the command a mail server's delivery target — an alias such as bounces: "|php /srv/site/bin/pramnos mail:bounces" with the site's Return-Path pointing at it records each report as it arrives. A folder is for a mailbox fetched to disk. A message in the folder that is not a report is left for a person to read.

A server that refuses the address itself while the message is being sent — 5.1.x in its reply, with a single recipient — is recorded as a hard bounce there and then: no report follows such a refusal. A refusal for any other reason (5.7.1 relaying denied) is the sender's problem, not the address's, and records nothing.

The table, and the screen

bounces keeps one row per address, by the sha256 of the lowercased address — the table tells someone who has an address whether it bounced, and nothing to someone who has only the table. Its layout is the one applications have kept under that name: the migration creates it where it is missing and, where an application already has it, keeps its rows and adds blocked and reason. Until the migration has run nothing is refused and nothing written.

A message's page in Email History says whether mail goes to its recipient, with Stop all mail to it (and a reason) or, for a refused one, Send to it again.

The outbox

send() opens an SMTP connection and waits for it. That is right when somebody is watching the screen for what the message contains — a second-factor code, a sign-in link — and wrong for everything else, where a visitor is paying 200–800ms for a message they are not waiting on.

$email->queue();     // composed now, delivered by mail:flush

Two things happen. The message is composed in this request — the mail type applied, the address checked against the unsubscribe records, the wrapper rendered — and then written to mails at status 2 instead of being dialled out.

Composed now rather than by the worker, and that is the point rather than an implementation detail: composition reads the request's language, its settings, its signed-in user and its unsubscribe token, and a worker running an hour later has none of them. A spool that stored the inputs and rendered later would send a different message from the one you composed — occasionally, and unreproducibly. What the row holds is the final string.

queue() returns whether the message was accepted for delivery, which is a weaker claim than send()'s "delivered". That is why it is a separate method and not a flag: no existing caller's understanding of its own return value changes.

An address that has opted out is not queued at all — it is recorded as refused, exactly as send() would.

Delivering it

./yourapp mail:flush                # send what is pending
./yourapp mail:flush --limit=50
./yourapp mail:flush --dry-run      # list it, send nothing

The framework schedules it every minute — a work process or a schedule:run crontab line is all it needs. Disable it with FrameworkSchedule::disable('mail:flush') only if something else sends the outbox; otherwise every queued message, the security alerts included, stays queued and nothing says it did not go. It moves each row to sent or failed in place, so a message has one row for its whole life and the history screens need no union.

It sends what is due: a row whose date is still in the future is mail queued for later, and stays pending until its time. So an application that queues a reminder for tomorrow writes the row with tomorrow's date, and mail:flush sends it then.

It is a worker, so unlike mail:prune it does its job by default — a dry run is an option, not the default.

// app.php
'mail' => ['outbox' => [
    'enabled'  => true,        // false: nothing sent, nothing given up on — the outbox waits
    'deadline' => 86400,       // seconds a message stays worth retrying, or 'never'
    'from'     => ['address' => 'team@example.com', 'name' => 'The Team'],  // a row with no sender
    'backoff'  => ['base' => 60, 'max' => 3600],  // seconds before a retry; false: every run
]],

deadline never (or --deadline=never) keeps every message until it goes or the server refuses it for good, through a backlog or an outage of any length.

A message the server refuses for now is not tried on every run: after its n-th such refusal it waits base · 2^(n−1) seconds, at most max — a minute, two, four, up to an hour. The count and the time are kept in the row's extrainfo, beside what it was queued with.

A message queued with names — setTo(['person@example.com' => 'A Person']) — keeps them in toname / fromname, and goes out as "A Person" <person@example.com>. The names of several recipients in one message are not kept: the row joins their addresses with commas, which a name may hold too. Without from, a message with no sender of its own is from admin_mail, with the site's name.

What an application does around each send goes in a subclass, registered under the same name in its console application, which replaces the framework's:

final class MailFlush extends \Pramnos\Console\Commands\MailFlush
{
    /** A reason not to send — the row is failed with it — or null. */
    protected function beforeSend(array $row, \Pramnos\Email\Email $email): ?string
    {
        return Blocklist::has($row['tomail']) ? 'On the blocklist' : null;
    }

    /** One message went. */
    protected function afterSent(array $row, \Pramnos\Email\Email $email): void
    {
        DailyStats::count('emails');
    }

    /** One failed for good: refused here, bounced, or refused by the server — not a retry. */
    protected function afterFailed(array $row, string $error): void
    {
    }

    /** A switch of the application's own, instead of mail.outbox.enabled. */
    protected function sendingEnabled(): bool
    {
        return Settings::getSetting('sendemails') !== 'no';
    }
}

How long sent mail is kept is mail:prune's, from mail.retention — see below.

The two answers that must not be confused

The server said Meaning What happens
5xx never — no such mailbox, rejected for policy failed at once, reason on the row
4xx not now left pending, retried next run
nothing recognisable — DNS, timeout, refused connection probably not now left pending

Treating a permanent refusal as retryable spends a full SMTP connection every run on an address that will not accept the message at any point. Treating a temporary one as fatal throws away a message because a mail server had a bad minute — and that failure is the invisible kind, because a row marked failed looks exactly like one that was genuinely undeliverable.

Bounded by time, not by attempts

There is no attempt counter. A real MTA retries for days and then bounces, because the useful question is whether something has been undeliverable long enough to give up on — not how many times it was tried. A row younger than the deadline is retried; past it, it fails with the reason.

'mail' => [
    'outbox' => ['deadline' => 86400],   // seconds; 24 hours by default
],

The sweep that fails the overdue rows runs on every mail:flush, as UPDATE mails … WHERE status = 2 AND date < ?. It needs an index that leads with status: the queued rows are a handful and the table is the whole history, so without one it reads every row, every minute. The framework's mails has idx_mails_status. For a mails that predates the framework's migrations, add_status_date_index_to_mails adds (status, date). It does nothing where any index already leads with status, whatever its name. On a log of tens of millions of rows, building the index is a maintenance window. Build it yourself at a quiet hour, or exclude the migration until then.

One at a time

The command holds a worker lock, because two overlapping runs would both read the same pending rows and both send them — and a duplicate security alert is a support ticket. Rows are marked after the send rather than before, so a crash mid-run resends at worst one message rather than losing it. For a notification that says somebody signed in to your account, that is the right way round.

From a notification

A notification declares queueable(): bool and the mail channel does the rest. See the Notifications guide.

What a queued message keeps

The mails row holds the address, the subject and the composed body. Everything else the send needs travels in the row's extrainfo column, as JSON, while the message waits:

  • copies (cc, bcc), replyto and the return path;
  • the attachment, the priority and the read receipt;
  • addHeader() headers, including a thread's In-Reply-To and References;
  • the unsubscribe link, mailto and list, and the one-click flag, from which List-Unsubscribe, List-Unsubscribe-Post and List-ID are built;
  • tracking, and structured data for Gmail.

mail:flush reads it back before sending, so a queued message leaves as it would have left at once. When the row reaches its final state, the column holds '' once the message is sent, or the transport's error if it failed. A queued row is never shown as having an error.

An attachment is kept as its path, so the file has to be there when the worker runs: queue from a file that stays, not a temporary upload. A message signed with signWith(), or sent through via() or withTransport(), is sent at once and never queued, because the row would have to hold the key or the password.

What to keep of a sent message, and for how long

mails is the table that grows without limit in every installation of this framework, and it grows for a reason that is easy to miss: it stores the rendered body. A password-reset mail is maybe two hundred bytes of facts — when, to whom, which module, did it send — wrapped around forty kilobytes of HTML. At a thousand messages a day that is fifteen gigabytes a year of markup nobody will read, and about eighty megabytes of the answers people actually ask.

So the policy has two stages:

// app/app.php
'mail' => ['retention' => [
    'strip_after'      => '90d',   // empty the body, keep the row
    'delete_after'     => '2y',    // remove the row
    'recipients_after' => '6m',    // recipient rows of finished campaigns
]],
  1. Strip the body. The row stays, and with it every question an operator asks months later — "did they ever get the code", "when did we last write to this address", "how many that week". What is lost is the message report: nothing can be read back out of a message whose body is gone.
  2. Delete the row. Eventually, because an audit log with no horizon is not a policy.

Deleting alone is the version people write, and it is the wrong shape: it throws away the cheap thing to save the expensive one. Stripping alone is also wrong — it leaves a table growing at eighty megabytes a year for ever.

./yourapp mail:prune                   # what the policy would do, and nothing else
./yourapp mail:prune --apply
./yourapp mail:prune --strip-after=90d --delete-after=2y --apply

A dry run by default, which is the opposite of most commands here and deliberate: this one deletes, the amount depends on two numbers somebody just typed, and the difference between 90d and 90 is three months of an audit trail. A duration that does not parse is no policy rather than a small number — a typo that meant "everything" would delete a mail log on a scheduled run and nothing would ever say why.

With nothing configured the command reports the table's size and explains the two stages, and assumes no policy. mail:prune --apply is scheduled daily at 04:10 and does nothing until somebody has decided; a default here would apply a guess to an audit trail.

The body goes from wherever it is kept: the row's content, its mail_bodies row, its file under var/emails, or its file on a storage disk. A file that another message still names is left until that message is stripped too, because identical bodies of the same hour are one file. A stripped row has an empty path, and reads as a message with no body.

Two smaller notes. Deleting runs before stripping — the other order strips a body and then deletes the row it belonged to, having written every one of those rows twice. And the sweep works in batches by id on every database, because a neglected table is millions of rows and one statement over all of them holds a lock long enough to make the maintenance the outage. A write the database refuses ends the run with what it did, logged.

recipients_after covers massmessagerecipients, the other table that grows without limit: one row per recipient per campaign, whose only remaining purpose once the campaign is finished is the count on its page — and the count is on the campaign row.

The mail log as a TimescaleDB hypertable

php bin/pramnos mail:hypertable --dry-run     # where the table is, and how much there is to move
php bin/pramnos mail:hypertable               # convert, then move the rows, newest first
php bin/pramnos mail:hypertable --no-drain    # convert only; move the rows in a later run

On TimescaleDB, mails can become a hypertable: monthly chunks, compressed once they are ninety days old, so statistics over a period read only that period. It is a console command rather than a migration because it is not cheaply undone, and when to run it is the installation's decision. Plain PostgreSQL and MySQL keep their table, and there the body store is what keeps it small.

It runs with the site up. The conversion takes milliseconds and touches only the catalogue: - mails is renamed mails_legacy; - a new mails is made like it, with the same columns, defaults, id sequence and indexes; - date is widened to bigint, so it outlives 2038; - the primary key becomes (id, date), as a hypertable needs; - the status index becomes (status, date), which is what the outbox reads.

New mail goes to the new table at once. The old rows then move newest first, in batches of ten thousand. Each batch is deleted from the old table and inserted into the new one in one statement, so a row is never in both. Sixty thousand rows a second were measured, and a run stopped part way is finished by the next. When nothing is left, mails_legacy is dropped.

Move the bodies first. Run mail:bodies-to-database before mail:hypertable. Moving a body also updates its mails row, and once mails is a hypertable an old row sits in a compressed chunk, where every update costs many times more. Both commands hold back the compression policy of the table they fill while they run, and compress once at the end.

date stays a Unix timestamp, so nothing that reads or writes it changes. While the rows move, history not yet moved is not in mails, and the screens fill from today backwards. Erasure, mail:prune and a person's copy of their mail read both tables, so none of them misses a message.

A person in the mail log: their copy, and their erasure

use Pramnos\Email\MailPersonalData;

MailPersonalData::export('someone@example.com');  // every message to them, with its body
MailPersonalData::erase('someone@example.com');   // how many messages were erased

An account's data export carries the first as its mail section, labelled Mail sent to you. Each message comes with its date, status, sender, subject and module, and with its body as it was sent, read from wherever it is kept.

An account erasure (Pramnos\Auth\AccountErasure) calls this with the account's address. Each message to the address stays, pseudonymised:

  • no address and no name;
  • the subject [erased];
  • no body, from wherever it was kept;
  • no outbox state or error that names the person.

What survives is that a message of this module went out then, with this outcome: the audit log's question, without the person. A newsletter body that other readers share stays, for them. The tracking row keeps its counts, and loses the address and the subject.

The address matches however it was stored: Someone@Example.com is the same person.

Two records are left on purpose: - bounces holds only a hash, which tells nothing to somebody without the address. - emailoptouts is the record that this address asked not to be mailed. Erasing it would undo the request.

Mail from a customer's own domain: its signature and its server

By default every message leaves through the installation's SMTP server (the smtp_* settings below), and the mail server signs it with DKIM for the installation's domain. A Virtualmin or Postfix server with OpenDKIM does this, and so does Amazon SES. Nothing here changes that.

Some mail is sent from somebody else's address: a reply to a press release from a customer organisation's own mailbox, for example. For DMARC to pass, that message needs a DKIM signature for the customer's domain, which the relay cannot provide because it does not hold the customer's key. Sometimes it also has to leave through the customer's own mail server. Both are opt-in, per message.

Signing for the customer's domain

use Pramnos\Email\DkimKey;

// Once, when the customer sets the feature up:
$key = DkimKey::generate('customer.example', 'pramnos1');
// Keep $key->privateKeyPem with the application's other secrets.
// The customer publishes a TXT record:
//   $key->recordName()   →  pramnos1._domainkey.customer.example
//   $key->recordValue()  →  v=DKIM1; k=rsa; p=MIIBIjANBgkq…

// For each message:
$email->setFrom('press@customer.example')
      ->signWith('customer.example', 'pramnos1', $privateKeyPem)
      ->send();

signWith() adds the signature last, after every other header, so nothing added later breaks it. The domain must be the From address's domain, or DMARC does not align. A key that does not load fails the send, and the key never appears in the error. (new \Pramnos\Email\DnsAuthentication())->inspect('customer.example', 'pramnos1') reports whether the record has been published (see What DNS says).

The record value is longer than 255 characters at 2048 bits. Most DNS screens split it themselves. One that does not takes it as several quoted strings, which DNS joins back together.

Sending through the customer's server

$email->via([
    'host'     => 'mail.customer.example',
    'port'     => 587,
    'user'     => 'press@customer.example',
    'password' => $password,
    'tls'      => true,
])->send();

The five keys mean what the smtp_* settings mean, and the scheme follows the same rule: port 465 is implicit TLS, 587 with tls is STARTTLS, another port with tls is implicit TLS, and without tls the connection is plain. The installation's settings are not read for this message. The password is never logged. A failure names the server: SMTP mail.customer.example:587: Expected response code "235" but got code "535" ….

withTransport($transport) takes any Symfony Mailer transport instead, such as an API transport or a failover pair.

Both are sent at once, never queued

queue() on a message with signWith(), via() or withTransport() sends it immediately, in this request. The outbox stores messages as rows and sends them later, so it would have to store the private key or the server's password in the row.

SMTP Configuration

Transport Types

The email system automatically selects the appropriate transport based on configuration:

// Port 465 - Implicit SSL (smtps://)
Settings::setSetting('smtp_port', 465);
Settings::setSetting('smtp_tls', 'yes');

// Port 587 - STARTTLS (smtp://)
Settings::setSetting('smtp_port', 587);
Settings::setSetting('smtp_tls', 'yes');

// Port 25 - Plain SMTP (smtp://)
Settings::setSetting('smtp_port', 25);
Settings::setSetting('smtp_tls', 'no');

Debug Mode

Enable debug mode to troubleshoot email issues:

$email = new Email();
$email->setDebug(true)  // Enable debug logging
      ->setSubject('Test Email')
      ->setBody('<p>This is a test.</p>')
      ->setTo('test@example.com')
      ->send();

The debug lines go to the framework log: the SMTP host, user and port, the scheme chosen for the port, and whether a password is set. Nothing about the password itself is written, neither characters nor length, because that log is the one people paste into tickets.

Reading an email

Pramnos\Email\MailMessage::parse($raw) reads a saved or received email — .eml, or a message fetched from a mailbox — with the standard library: its subject, from, reply_to, message_id, references, date, to (every address it was sent to), text and attachments (name, type and bytes each). Multipart parts are walked at any depth, base64 and quoted-printable decoded, charsets and encoded-word headers turned to UTF-8; the plain-text part wins, and HTML is read as its words when it is all there is. original is the email it forwards, when it forwards one — from an attached message or the block a mail program writes above it — so the first sender is known. cleanSubject() drops Fwd: and its kin; looksLikeOne() says whether bytes are an email at all. It is not a full RFC 5322 reader: no S/MIME, no TNEF.

Error Handling

Checking for Errors

$email = new Email();
$success = $email->setSubject('Test')
                 ->setBody('<p>Test content</p>')
                 ->setTo('user@example.com')
                 ->send();

if (!$success) {
    // Check for errors
    if ($email->hasError()) {
        $errorMessage = $email->getLastError();
        $exception = $email->getLastException();

        echo "Email failed: " . $errorMessage;

        // Log the full exception if needed
        if ($exception) {
            \Pramnos\Logs\Logger::log("Email error: " . $exception->getTraceAsString());
        }
    }
}

Common Error Scenarios

  1. Authentication Failed: Check SMTP credentials
  2. Connection Timeout: Verify host and port settings
  3. TLS/SSL Issues: Ensure proper encryption settings
  4. Attachment Not Found: Verify file paths and permissions
  5. Invalid Recipients: Check email address formats

Best Practices

1. Configuration Management

// Store sensitive settings securely
Settings::setSetting('smtp_pass', env('SMTP_PASSWORD'));
Settings::setSetting('smtp_user', env('SMTP_USERNAME'));

2. Template Usage

// Use templates for consistent email design
$email = new Email();
$template = file_get_contents('templates/welcome-email.html');
$template = str_replace('{{username}}', $user->name, $template);

$email->setBody($template);

3. Batch Processing

// For large email lists, use batch processing
$emails = ['user1@example.com', 'user2@example.com', /* ... */];

foreach (array_chunk($emails, 50) as $batch) {
    foreach ($batch as $recipient) {
        $email = new Email();
        $email->setTo($recipient)
              ->setSubject('Newsletter')
              ->setBody($content)
              ->send();

        // Add delay to avoid rate limiting
        usleep(100000); // 0.1 second delay
    }
}

4. Error Logging

$email = new Email();
$success = $email->send();

if (!$success) {
    \Pramnos\Logs\Logger::log(
        "Email failed to: " . $email->to . 
        " Error: " . $email->getLastError()
    );
}

5. Testing

// Use a test mode flag
if (defined('EMAIL_TEST_MODE') && EMAIL_TEST_MODE) {
    // Override recipient for testing
    $email->setTo('test@yoursite.com');
}

API Reference

Email Class Methods

Configuration Methods

  • setSubject(string $subject) - Set email subject
  • setBody(string $body) - Set HTML email body
  • setTo(mixed $to) - Set recipient(s)
  • setFrom(mixed $from) - Set sender
  • setCc(mixed $cc) - Set CC recipients
  • setBcc(mixed $bcc) - Set BCC recipients
  • addHeader(string $name, string $value) - Add custom header

Sending Methods

  • send() - Send the email (returns boolean)
  • static sendMail(...) - Static method for quick sending

Tracking Methods

  • enableTracking(string $id = null) - Enable email tracking
  • static handleTrackingRequest(string $trackingId) - Handle tracking pixel requests

Error Handling Methods

  • hasError() - Check if last send operation had errors
  • getLastError() - Get last error message
  • getLastException() - Get last exception object

Debug Methods

  • setDebug(bool $enable) - Enable/disable debug logging

Properties

Email Content

  • $subject - Email subject line
  • $body - Email body (HTML)
  • $to - Recipient(s)
  • $from - Sender
  • $cc - CC recipients
  • $bcc - BCC recipients
  • $replyto - Reply-to address

Settings

  • $priority - Email priority (1-5)
  • $attach - Attachment file path
  • $batch - Batch sending mode

Advanced Headers

  • $sendReceipt - Request read receipt
  • $returnPath - Return path for bounces
  • $organization - Organization header
  • $abuse - Abuse report address
  • $unsubscribe - Unsubscribe link
  • $headers - Custom headers array

Tracking

  • $trackingId - Tracking identifier
  • $debug - Debug mode flag

Troubleshooting

Common Issues

  1. SMTP Authentication Failed
  2. Verify username/password
  3. Check if app-specific passwords are required
  4. Ensure SMTP is enabled on your email provider

  5. Connection Refused

  6. Verify SMTP host and port
  7. Check firewall settings
  8. Ensure TLS/SSL settings match provider requirements

  9. Emails Going to Spam

  10. Set up SPF, DKIM, and DMARC records
  11. Use proper from addresses
  12. Avoid spam trigger words in subject/content

  13. Tracking Not Working

  14. Check 'email' => ['tracking' => true] in app/app.php
  15. Check the message is list mail (offerUnsubscribe() or a type() with a list) and called enableTracking() — transactional mail is never tracked
  16. Check the emailtracking and emailtrackingclicks tables exist (migrate)
  17. Ensure /emailpixel and /emailclick are reachable from outside

For additional support, refer to the framework's logging system to capture detailed error information.