Skip to content

📧 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:

PropertyDefault Value
fromemailsender from MODX system settings
fromNamesite_name from MODX system settings
senderemailsender from MODX system settings
isHtmltrue — emails are sent in HTML format

Example:

php
$mail = Mail::to('user@example.com');  
// $mail->from will be equal to $modx->getOption('emailsender')

✅ Quick Example ​

php
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:

php
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.

php
->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 ​

php
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 ​

php
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 ​

StateWhat it means
queuedwaiting for a worker: either untried, or sitting out a pause
sendingclaimed by a worker right now
sentit went
failedattempts exhausted
skippedheld 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.

bash
* * * * * 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 --purge

Once 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 ​

SettingDefaultWhat it does
pageblocks_mail_max_attempts5how many times to try
pageblocks_mail_batch50messages per cron pass
pageblocks_mail_throttle250000pause between messages, in microseconds
pageblocks_mail_keep_days90how long to keep settled rows; 0 — never clean
pageblocks_site_modeproddev turns on the safety valve
pageblocks_admin_email—who may still be written to in dev
pageblocks_dev_mail_groupsAdministratorwhose 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, and sender values.
  • Allows easy configuration of recipients, copies, and sending format.
  • Every message is journalled, and a failure is retried on a growing delay.

© PageBlocks 2019-present