📧 Mail — Sending Emails in PageBlocks
The Mail class is designed for composing and sending emails using the standard modMail service in MODX. In PageBlocks, it simplifies email handling and supports Fenom templates for content generation.
📌 Purpose
Mail wraps the modMail functionality and allows configuring all email parameters via method chaining. The email body can be set directly or generated via a Fenom template.
⚙️ Default Values
When creating a Mail object, some parameters are automatically pulled from MODX system settings:
| Property | Default Value |
|---|---|
from | emailsender from MODX system settings |
fromName | site_name from MODX system settings |
sender | emailsender from MODX system settings |
isHtml | true — emails are sent in HTML format |
Example:
$mail = Mail::to('user@example.com');
// $mail->from will be equal to $modx->getOption('emailsender')✅ Quick Example
use Boshnik\PageBlocks\Support\Mail;
Mail::to('recipient@example.com')
->subject('Welcome')
->message('<p>Thank you for registering!</p>')
->send();🗂️ Class Methods
➡️ Mail::to(string $email, string $name = ''): Mail
Sets the recipient's email address, and optionally the recipient's name.
➡️ subject(string $text): Mail
Sets the email subject.
➡️ message(string $html): Mail
Sets the email body directly (HTML or plain text).
➡️ view(string $tpl, array $data = []): Mail
Renders the email using a Fenom template and provided data.
Example:
Mail::to('user@example.com')
->subject('Your Order')
->view('file:emails/order', ['order' => $order])
->send();➡️ attach($file, string $as = ''): Mail
Attaches a file to the email. Takes a plain path as a string, or an array with the path under either url or path, plus an optional name shown to the recipient. With a plain path, the second argument is the name the recipient sees; without it, the file's own.
->attach(MODX_BASE_PATH . 'assets/docs/contract.pdf');
->attach([
'url' => MODX_BASE_PATH . 'assets/docs/contract.pdf',
'name' => 'Contract.pdf'
]);A file that has gone missing by the time the message is sent does not cancel it: the mail goes out without the attachment and the fact is logged.
➡️ replyTo(string $email): Mail
Sets the reply-to address.
➡️ cc(string $email): Mail
Adds a CC recipient.
➡️ bcc(string $email): Mail
Adds a BCC recipient.
➡️ from(string $email, string $name = ''): Mail
Changes the sender's email and name.
➡️ sender(string $email): Mail
Changes the MAIL_SENDER.
➡️ setHTML(bool $html = true): Mail
Specifies whether to send the email in HTML or plain text format.
➡️ header(string $name, string $value): Mail
Adds a custom header — List-Unsubscribe on a digest, say: mailbox providers penalise bulk mail that ships without one.
➡️ send(): bool
Sends the email now, and returns whether it went out at this moment. Recipient, subject and body come from the chain — there are no arguments.
A failed send() is not lost: it keeps its place in the queue and is retried. See Retry on failure.
➡️ queue(?DateTimeInterface $at = null): bool
Records the message and returns, without touching SMTP. See The queue.
➡️ context(string $key): Mail
Tags the message for the journal. See context.
🗂️ Example: Fenom + Attachment + CC/BCC
Mail::to('user@example.com')
->subject('Your Order Has Been Accepted')
->view('emails/order', ['order' => $order])
->attach([
'url' => MODX_BASE_PATH . 'assets/invoices/invoice.pdf',
'name' => 'Invoice.pdf'
])
->cc('manager@example.com')
->bcc('admin@example.com')
->send();📝 Error Logging
If an email fails to send, an error will be logged in the MODX system log (core/cache/logs/error.log) with the ERROR level and the [Mail] prefix.
The journal
Every message is recorded in pb_mails before it is handed to SMTP. A row says when it was composed, how many times it was tried, what the server answered and whether it eventually went.
That is not a convenience. Before it existed, sending left exactly one trace — a line in the MODX log, and only on failure. "The customer says the key never arrived" could not be settled with that: the log rotates, a successful send is never written to it at all, and "it went out, check your spam" and "it never went out" looked identical — like nothing.
The body of the message is stored too, because without it there is nothing to retry.
The queue
Mail::to($email)->subject($subject)->message($html)->context('app.register')->queue();
// Defer it — an abandoned-cart reminder, say.
Mail::to($email)->subject($subject)->message($html)->queue(now()->addHours(3));For anything a live person is waiting on, queue() is the right choice: an unreachable mail server turns a synchronous send into a page that hangs until the TCP timeout and then errors, and the message is lost either way.
If the journal table is missing, queue() falls back to sending immediately
Deferring is an optimisation, not a precondition — a message must not vanish because a migration has not been applied.
Retry on failure
You do not have to ask for retries: a failed send() stays in the queue. Calling code need not know the queue exists.
The schedule of pauses is a minute, five, fifteen, an hour, six hours. Five attempts fit into roughly eight hours.
The front of that is short on purpose. Most failures are a busy or briefly unreachable mail server and clear up by themselves; by the fourth failure the cause is no longer a hiccup but the configuration or a block list, and frequent attempts damage the sender's reputation rather than improving the odds.
Row states
| State | What it means |
|---|---|
queued | waiting for a worker: either untried, or sitting out a pause |
sending | claimed by a worker right now |
sent | it went |
failed | attempts exhausted |
skipped | held back by the site's development mode |
A row returns to queued after a failure rather than staying failed: the state has to say what happens next, and what happens next is another attempt. failed means "nothing more will happen by itself".
context — a label, not decoration
A free string: shop.invoice_copy, app.register, repository.license. The journal filters by it. Without a label, "did yesterday's invoices go out" is answered by reading subject lines one after another; with one, by a single filter. Worth setting anywhere mail goes out in a batch.
Draining the queue: cron
Without cron the queue only moves when somebody presses "Send now" in the manager — that is, mail goes out when someone is looking at the screen.
* * * * * cd /path/to/site && /usr/bin/php core/components/pageblocks/bin/mail-queue.php >> /dev/null 2>&1
30 4 * * * cd /path/to/site && /usr/bin/php core/components/pageblocks/bin/mail-queue.php --purgeOnce a minute is not excessive: a pass with no work is one indexed query, and a message someone is waiting on should not sit for five minutes because of a round number in crontab.
Flags: --limit=N, --context=KEY, --purge, --dry (show the queue, send nothing).
Run it as the web server's user, not as root
Otherwise you get root-owned cache files the site cannot overwrite afterwards.
Two workers at once
A row is claimed with a conditional UPDATE: both processes see it free, both try to take it, the database gives it to exactly one. The loser gets zero affected rows and moves on. Without that, cron every minute with a pass that takes a minute and a half would send everything twice.
A row held for more than fifteen minutes is treated as abandoned — the process died mid-send — and goes back into the queue. That can produce a duplicate: the dead process may have reached SMTP. The choice is deliberate — a second copy of a license key is an annoyance, a key that never arrives is a refund.
The journal in the manager
The Mail menu item — a page of its own, next to "Reusable blocks" and the "Recycle bin". It used to be a constructor tab: the constructor is where you build the schema, while the journal is what you open when a message did not arrive — and the person asking that is not the integrator but whoever runs the site.
Deliberately not PRO: what is paid for is running a data section, while mail is infrastructure, and a site on the free build needs to see whether a message went out just as much as anyone else.
It cannot be written to by hand — no creating a row, no editing one. A journal you can type anything into answers "was this sent?" with the operator's last edit, which is worse than having none.
Available: filtering by state (including Needs attention — failed, plus those that have already missed once), filtering by source, search, viewing the whole message, Send again, Send now and Remove old records.
The body is not sent with the list: twenty lines of HTML invoice per row is megabytes over the wire for a list of subjects. The viewer requests one row separately and shows the body in an iframe with an empty sandbox — it is someone else's markup, and it does not belong in the manager's DOM on either security or styling grounds.
Settings
| Setting | Default | What it does |
|---|---|---|
pageblocks_mail_max_attempts | 5 | how many times to try |
pageblocks_mail_batch | 50 | messages per cron pass |
pageblocks_mail_throttle | 250000 | pause between messages, in microseconds |
pageblocks_mail_keep_days | 90 | how long to keep settled rows; 0 — never clean |
pageblocks_site_mode | prod | dev turns on the safety valve |
pageblocks_admin_email | — | who may still be written to in dev |
pageblocks_dev_mail_groups | Administrator | whose groups may still be written to in dev |
The development safety valve
On a dev site, Mail only lets through messages addressed to pageblocks_admin_email and to the groups in pageblocks_dev_mail_groups. Everyone else gets nothing, the row is marked skipped, and send() returns true.
That is intended: calling code treats false as a delivery failure, and a newsletter would then unsubscribe live subscribers because of the site's mode.
After restoring a production dump onto a dev site, set pageblocks_site_mode = dev first
The dump brings prod with it and the valve ends up switched off. A missing setting also reads as prod.
Storage and cleanup
pb_mails holds message bodies, which makes it the heaviest table in the component. --purge deletes rows that have served their time.
Cleanup only touches settled rows: sent, skipped and finally failed. A message still waiting in the queue does not become stale just because it was composed two weeks ago — deleting it would mean losing mail silently, which is the very thing the journal exists to prevent.
📚 Summary
Mail— a convenient class for sending emails with Fenom templates and attachments.- Uses MODX settings for default
from,fromName, andsendervalues. - Allows easy configuration of recipients, copies, and sending format.
- Every message is journalled, and a failure is retried on a growing delay.