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¶
- Overview
- Basic Usage
- Configuration
- Advanced Features
- Tracking: opens and clicks
- A message to one account
- A message to many accounts
- What kinds of mail this application sends
- Unsubscribing, and what Gmail requires
- The line the inbox shows beside the subject
- Dark mode
- Accessibility in the message
- What DNS says
- Bounces, and addresses mail does not go to
- The outbox
- Retention
- SMTP Configuration
- Error Handling
- Best Practices
- 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.
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:
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:
| 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.shareability 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 insavedmessages(migrationcreate_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 avalueinserts 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()withvaluesFor($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-languagewhen the form gives one. Undo puts back the subject and the body it replaced. The call is logged and costed as every call is (purposecompose); the body comes back cleaned to what a message may carry. - The signature is the account's own (
usersettingsmail.signature), edited atComposer/signaturein 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, withuserid0 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:
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¶
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 themailto: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/alternativewith a plain-text part, whichEmailbuilds 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.
sourceis kept when it is a short identifier ([a-z0-9_-], up to 32),formotherwise; 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/jsongets{"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.
The consent trail¶
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.
Or on a notification, beside the other mail options:
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.langon<html>, from the language the message was composed in — whichNotifierand the mass dispatcher both switch to the recipient's before composing. An emptylangis 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_tagskeeps the anchor text and throws thehrefaway, 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_tagsremoves 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:
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
ConfirmActionhandler 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_secreton 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()answersnullfor 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¶
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:
'email' => ['tracking' => true]inapp.php. Absent means off.- The message belongs to a list —
offerUnsubscribe()was called on it. Transactional mail is never tracked at any setting. 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():
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¶
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 atmail_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¶
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
--selectorand it becomes a real check; without one it reports "not checked" and does not fail the domain. p=noneclears the bar. Gmail and Yahoo ask for a DMARC record.p=noneis one, and failing everybody atp=nonewould 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.
- DMARC at
p=quarantineorp=reject. Not optional and not negotiable;p=nonedisables BIMI at every provider. - An SVG Tiny PS — a restricted SVG profile, square, with a solid background — at a public HTTPS URL.
- A TXT record at
default._bimi.<domain>readingv=BIMI1; l=https://…/logo.svg. - 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:
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.
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.
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),replytoand the return path; - the attachment, the priority and the read receipt;
addHeader()headers, including a thread'sIn-Reply-ToandReferences;- the unsubscribe link, mailto and list, and the one-click flag, from which
List-Unsubscribe,List-Unsubscribe-PostandList-IDare 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
]],
- 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.
- 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¶
- Authentication Failed: Check SMTP credentials
- Connection Timeout: Verify host and port settings
- TLS/SSL Issues: Ensure proper encryption settings
- Attachment Not Found: Verify file paths and permissions
- 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 subjectsetBody(string $body)- Set HTML email bodysetTo(mixed $to)- Set recipient(s)setFrom(mixed $from)- Set sendersetCc(mixed $cc)- Set CC recipientssetBcc(mixed $bcc)- Set BCC recipientsaddHeader(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 trackingstatic handleTrackingRequest(string $trackingId)- Handle tracking pixel requests
Error Handling Methods¶
hasError()- Check if last send operation had errorsgetLastError()- Get last error messagegetLastException()- 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
Related Documentation¶
- Framework Guide - Core framework concepts
- Framework Guide - Application structure and settings
- Database Guide - Database operations for tracking
- Logging Guide - Error logging and debugging
- Theme Guide - Email templates and theming
Troubleshooting¶
Common Issues¶
- SMTP Authentication Failed
- Verify username/password
- Check if app-specific passwords are required
-
Ensure SMTP is enabled on your email provider
-
Connection Refused
- Verify SMTP host and port
- Check firewall settings
-
Ensure TLS/SSL settings match provider requirements
-
Emails Going to Spam
- Set up SPF, DKIM, and DMARC records
- Use proper from addresses
-
Avoid spam trigger words in subject/content
-
Tracking Not Working
- Check
'email' => ['tracking' => true]inapp/app.php - Check the message is list mail (
offerUnsubscribe()or atype()with a list) and calledenableTracking()— transactional mail is never tracked - Check the
emailtrackingandemailtrackingclickstables exist (migrate) - Ensure
/emailpixeland/emailclickare reachable from outside
For additional support, refer to the framework's logging system to capture detailed error information.